Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save lunamoth/4e7c39e62891392533dbc3b3cb988d27 to your computer and use it in GitHub Desktop.
Save lunamoth/4e7c39e62891392533dbc3b3cb988d27 to your computer and use it in GitHub Desktop.
manifest.json
{
"manifest_version": 3,
"name": "새 탭 노트",
"version": "12.0",
"description": "새 탭을 깔끔하고 생산적인 폴더형 노트 공간으로 바꿔보세요.",
"permissions": [
"storage",
"downloads"
],
"chrome_url_overrides": {
"newtab": "newtab.html"
}
}
script.js
document.addEventListener('DOMContentLoaded', () => {
// --- 상수 정의 ---
const CONSTANTS = {
ITEM_TYPE: { FOLDER: 'folder', NOTE: 'note' },
MODAL_TYPE: { PROMPT: 'prompt', CONFIRM: 'confirm' },
TOAST_TYPE: { SUCCESS: 'success', ERROR: 'error' },
LS_KEY: 'newTabNoteLastSession_v10.0', // 버전 업데이트
ALL_NOTES_ID: 'all-notes-virtual-id',
CLASSES: {
DRAGGING: 'dragging',
DROP_TARGET: 'drop-target',
PINNED: 'pinned',
ACTIVE: 'active'
}
};
// --- DOM 요소 캐싱 ---
const getEl = id => document.getElementById(id);
const folderList = getEl('folder-list'), noteList = getEl('note-list');
const foldersPanel = getEl('folders-panel');
const addFolderBtn = getEl('add-folder-btn'), addNoteBtn = getEl('add-note-btn');
const notesPanelTitle = getEl('notes-panel-title');
const searchInput = getEl('search-input'), clearSearchBtn = getEl('clear-search');
const noteSortSelect = getEl('note-sort-select');
const exportBtn = getEl('export-btn'), importBtn = getEl('import-btn'), importFileInput = getEl('import-file-input');
const editorContainer = getEl('editor-container'), placeholderContainer = getEl('placeholder-container');
const noteTitleInput = getEl('note-title-input'), noteContentTextarea = getEl('note-content-textarea');
const editorFooter = getEl('editor-footer'), toastContainer = getEl('toast-container');
const modal = getEl('modal'), modalTitle = getEl('modal-title'), modalMessage = getEl('modal-message');
const modalForm = getEl('modal-form'), modalInput = getEl('modal-input');
const modalConfirmBtn = getEl('modal-confirm-btn'), modalCancelBtn = getEl('modal-cancel-btn');
const itemTemplate = getEl('item-template');
// --- 상태 및 유틸리티 ---
let state = { folders: [], activeFolderId: null, activeNoteId: null, searchTerm: '', noteSortOrder: 'updatedAt_desc', noteMap: new Map() };
let debounceTimer;
let draggedItemInfo = { id: null, type: null, sourceFolderId: null };
let isSaving = false;
const formatDate = d => new Date(d).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' });
const debounce = (fn, delay) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(fn, delay); };
const _buildNoteMap = () => {
state.noteMap.clear();
for (const folder of state.folders) {
for (const note of folder.notes) {
state.noteMap.set(note.id, { note, folderId: folder.id });
}
}
};
// --- 상태 관리 ---
const setState = (newState, renderFlags = { all: true }) => {
Object.assign(state, newState);
if (renderFlags.all) renderAll();
else {
if (renderFlags.folders) renderFolders();
if (renderFlags.notes) renderNotes();
if (renderFlags.editor) renderEditor();
}
saveSession();
};
// --- 데이터 관리 ---
const saveData = async () => { try { await chrome.storage.local.set({ appState: { folders: state.folders } }); } catch (e) { console.error("Error saving state:", e); }};
const saveSession = () => localStorage.setItem(CONSTANTS.LS_KEY, JSON.stringify({ f: state.activeFolderId, n: state.activeNoteId, s: state.noteSortOrder }));
const loadData = async () => {
try {
const result = await chrome.storage.local.get('appState');
let initialState = { folders: [], activeFolderId: null, activeNoteId: null, searchTerm: '', noteSortOrder: 'updatedAt_desc', noteMap: new Map() };
if (result.appState && result.appState.folders?.length > 0) {
initialState = { ...initialState, ...result.appState };
// [개선] 데이터 구조 호환성 처리 (noteOrder 추가)
initialState.folders.forEach(f => {
if (!f.noteOrder) {
f.noteOrder = f.notes.map(n => n.id);
}
});
const lastSession = JSON.parse(localStorage.getItem(CONSTANTS.LS_KEY));
if (lastSession) {
initialState.activeFolderId = lastSession.f;
initialState.activeNoteId = lastSession.n;
initialState.noteSortOrder = lastSession.s || 'updatedAt_desc';
}
if (!initialState.activeFolderId || (initialState.activeFolderId !== CONSTANTS.ALL_NOTES_ID && !initialState.folders.some(f => f.id === initialState.activeFolderId))) {
initialState.activeFolderId = CONSTANTS.ALL_NOTES_ID;
}
} else {
const now = Date.now();
const fId = `folder-${now}`, nId = `note-${now + 1}`;
const newNote = { id: nId, title: "🎉 환영합니다!", content: "새 탭 노트에 오신 것을 환영합니다! 🚀", createdAt: now, updatedAt: now, isPinned: false };
initialState = { ...initialState, folders: [{ id: fId, name: "🌟 첫 시작 폴더", notes: [newNote], noteOrder: [nId] }], activeFolderId: fId, activeNoteId: nId };
await saveData(initialState);
}
state = initialState;
_buildNoteMap();
noteSortSelect.value = state.noteSortOrder;
renderAll();
} catch (e) { console.error("Error loading data:", e); }
};
// --- UI 피드백 (Toast, Modal) ---
const showToast = (message, type = CONSTANTS.TOAST_TYPE.SUCCESS) => {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
toastContainer.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
};
const showModal = ({ type, title, message = '', placeholder = '', initialValue = '', confirmText = '확인', cancelText = '취소' }) => {
return new Promise(resolve => {
modalTitle.textContent = title;
modalMessage.textContent = message;
modalConfirmBtn.textContent = confirmText;
modalCancelBtn.textContent = cancelText;
modalInput.style.display = type === CONSTANTS.MODAL_TYPE.PROMPT ? 'block' : 'none';
modalInput.value = initialValue;
modalInput.placeholder = placeholder;
modal.showModal();
if (type === CONSTANTS.MODAL_TYPE.PROMPT) modalInput.focus();
const handleClose = () => {
modal.removeEventListener('close', handleClose);
resolve(modal.returnValue === 'confirm' ? (type === CONSTANTS.MODAL_TYPE.PROMPT ? modalInput.value : true) : null);
};
modal.addEventListener('close', handleClose);
});
};
// --- [개선] 정렬 로직 함수화 ---
const sortNotes = (notes, sortOrder, folderNoteOrder = []) => {
const sorted = [...notes]; // 원본 배열 수정을 피하기 위해 복사
sorted.sort((a, b) => {
// 고정된 노트는 항상 최상단에 위치
if (a.isPinned !== b.isPinned) return b.isPinned - a.isPinned;
// '수동 정렬' 모드 처리
if (sortOrder === 'manual') {
const indexA = folderNoteOrder.indexOf(a.id);
const indexB = folderNoteOrder.indexOf(b.id);
// noteOrder 배열에 없는 경우(예: 신규 노트) 최상단으로
if (indexA === -1 && indexB !== -1) return -1;
if (indexB === -1 && indexA !== -1) return 1;
return indexA - indexB;
}
// 다른 정렬 모드
switch (sortOrder) {
case 'createdAt_desc': return b.createdAt - a.createdAt;
case 'title_asc': return (a.title || '').localeCompare(b.title || '', 'ko-KR');
case 'title_desc': return (b.title || '').localeCompare(a.title || '', 'ko-KR');
case 'updatedAt_desc':
default:
return b.updatedAt - a.updatedAt;
}
});
return sorted;
};
// --- 렌더링 (DOM Diffing 최적화 적용) ---
const createListItemElement = (item, type) => {
const fragment = itemTemplate.content.cloneNode(true);
const li = fragment.querySelector('.item-list-entry');
const actionsDiv = fragment.querySelector('.item-actions');
li.dataset.id = item.id;
if (type === CONSTANTS.ITEM_TYPE.NOTE) {
const pinBtn = document.createElement('button');
pinBtn.className = `icon-button pin-btn`;
pinBtn.textContent = '📌';
pinBtn.title = '노트 고정';
actionsDiv.appendChild(pinBtn);
}
if (item.id !== CONSTANTS.ALL_NOTES_ID) {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'icon-button delete-item-btn';
deleteBtn.textContent = '🗑️';
deleteBtn.title = type === CONSTANTS.ITEM_TYPE.NOTE ? '노트 삭제' : '폴더 삭제';
actionsDiv.appendChild(deleteBtn);
} else {
li.draggable = false;
}
updateListItemElement(li, item, type);
return li;
};
const highlightText = (container, text, term) => {
container.innerHTML = '';
if (!term) {
container.textContent = text;
return;
}
const regex = new RegExp(`(${term.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
parts.forEach(part => {
if (part.toLowerCase() === term.toLowerCase()) {
const mark = document.createElement('mark');
mark.textContent = part;
container.appendChild(mark);
} else {
container.appendChild(document.createTextNode(part));
}
});
};
const updateListItemElement = (li, item, type) => {
const nameSpan = li.querySelector('.item-name');
const snippetDiv = li.querySelector('.item-snippet');
const folderTag = li.querySelector('.item-folder-tag');
li.classList.toggle(CONSTANTS.CLASSES.ACTIVE, item.id === (type === CONSTANTS.ITEM_TYPE.FOLDER ? state.activeFolderId : state.activeNoteId));
if (type === CONSTANTS.ITEM_TYPE.FOLDER) {
highlightText(nameSpan, item.name, '');
} else {
const pinBtn = li.querySelector('.pin-btn');
const isPinned = !!item.isPinned;
li.classList.toggle(CONSTANTS.CLASSES.PINNED, isPinned);
if(pinBtn) pinBtn.classList.toggle(CONSTANTS.CLASSES.PINNED, isPinned);
const title = item.title || '📝 제목 없음';
highlightText(nameSpan, title, state.searchTerm);
// [개선] 검색 시 내용에 일치하는 부분이 있으면 항상 스니펫을 보여주도록 수정
if (state.searchTerm && item.content?.toLowerCase().includes(state.searchTerm.toLowerCase())) {
const content = item.content;
const term = state.searchTerm;
const index = content.toLowerCase().indexOf(term.toLowerCase());
const start = Math.max(0, index - 20);
const end = Math.min(content.length, index + term.length + 30);
const snippet = (start > 0 ? '...' : '') + content.substring(start, end) + (end < content.length ? '...' : '');
highlightText(snippetDiv, snippet, term);
snippetDiv.style.display = 'block';
} else {
snippetDiv.style.display = 'none';
}
if (state.activeFolderId === CONSTANTS.ALL_NOTES_ID) {
const { folder } = findItem(item.id, CONSTANTS.ITEM_TYPE.NOTE);
folderTag.textContent = folder?.name || '';
folderTag.style.display = folder ? 'inline-block' : 'none';
} else {
folderTag.style.display = 'none';
}
}
};
const renderList = (listElement, items, type) => {
const itemMap = new Map(items.map(item => [item.id, item]));
const existingElements = new Map(Array.from(listElement.children).filter(el => el.dataset.id).map(el => [el.dataset.id, el]));
existingElements.forEach((el, id) => {
if (!itemMap.has(id)) el.remove();
});
let lastElement = null;
items.forEach(item => {
let currentEl = existingElements.get(item.id);
if (currentEl) {
updateListItemElement(currentEl, item, type);
} else {
currentEl = createListItemElement(item, type);
}
if (lastElement) {
if (lastElement.nextElementSibling !== currentEl) {
lastElement.after(currentEl);
}
} else {
if (listElement.firstElementChild !== currentEl) {
listElement.prepend(currentEl);
}
}
lastElement = currentEl;
});
const pinDividerId = 'pin-divider';
listElement.querySelector(`#${pinDividerId}`)?.remove();
if (type === CONSTANTS.ITEM_TYPE.NOTE) {
const firstUnpinnedIndex = items.findIndex(item => !item.isPinned);
if (firstUnpinnedIndex > 0 && firstUnpinnedIndex < items.length) {
const divider = document.createElement('li');
divider.id = pinDividerId;
divider.className = 'pin-divider';
const firstUnpinnedEl = listElement.querySelector(`[data-id="${items[firstUnpinnedIndex].id}"]`);
if(firstUnpinnedEl) {
listElement.insertBefore(divider, firstUnpinnedEl);
}
}
}
};
const renderFolders = () => {
const allFolders = [{ id: CONSTANTS.ALL_NOTES_ID, name: '📚 모든 노트' }, ...state.folders];
renderList(folderList, allFolders, CONSTANTS.ITEM_TYPE.FOLDER);
};
const renderNotes = () => {
addNoteBtn.style.display = 'block';
let notesToDisplay = [];
let folderName = '';
let currentFolder = null;
if (state.activeFolderId === CONSTANTS.ALL_NOTES_ID) {
folderName = '모든 노트';
notesToDisplay = state.folders.flatMap(f => f.notes);
addNoteBtn.style.display = 'none';
} else {
const { item: activeFolder } = findItem(state.activeFolderId, CONSTANTS.ITEM_TYPE.FOLDER);
currentFolder = activeFolder;
if (!activeFolder) {
notesPanelTitle.textContent = '📝 노트';
addNoteBtn.style.display = 'none';
noteList.innerHTML = `<p style="padding:12px; color:var(--font-color-dim); font-size:14px; text-align:center;">👈 먼저 폴더를<br>선택해주세요.</p>`;
return;
}
folderName = activeFolder.name;
notesToDisplay = activeFolder.notes;
}
notesPanelTitle.textContent = `📝 ${folderName}`;
const filteredNotes = notesToDisplay.filter(n =>
(n.title || '').toLowerCase().includes(state.searchTerm.toLowerCase()) ||
(n.content || '').toLowerCase().includes(state.searchTerm.toLowerCase())
);
// [개선] `sortNotes` 함수 사용
const sortedNotes = sortNotes(filteredNotes, state.noteSortOrder, currentFolder?.noteOrder);
noteSortSelect.value = state.noteSortOrder; // UI와 상태 동기화
if (sortedNotes.length === 0) {
let message;
if (state.searchTerm) {
message = '🤷‍♂️<br>검색 결과가 없어요.';
} else if (state.activeFolderId !== CONSTANTS.ALL_NOTES_ID) {
message = `✨<br>첫 노트를 작성해보세요!<br><button class="placeholder-button">새 노트 추가</button>`;
noteList.innerHTML = `<div class="placeholder">${message}</div>`;
noteList.querySelector('.placeholder-button')?.addEventListener('click', handleAddNote);
return;
} else {
message = '텅... 🤷‍♂️<br>노트가 없어요.';
}
noteList.innerHTML = `<div class="placeholder">${message}</div>`;
} else {
renderList(noteList, sortedNotes, CONSTANTS.ITEM_TYPE.NOTE);
}
};
const renderEditor = () => {
const { item: activeNote } = findItem(state.activeNoteId, CONSTANTS.ITEM_TYPE.NOTE);
if (activeNote) {
editorContainer.style.display = 'flex';
placeholderContainer.style.display = 'none';
if (document.activeElement !== noteTitleInput) noteTitleInput.value = activeNote.title;
if (document.activeElement !== noteContentTextarea) noteContentTextarea.value = activeNote.content;
editorFooter.innerHTML = '';
const wordCount = (activeNote.content || '').split(/\s+/).filter(Boolean).length;
const createSpan = (text) => {
const span = document.createElement('span');
span.textContent = text;
return span;
};
editorFooter.appendChild(createSpan(`단어: ${wordCount}`));
editorFooter.appendChild(createSpan(`생성일: ${formatDate(activeNote.createdAt)}`));
editorFooter.appendChild(createSpan(`수정일: ${formatDate(activeNote.updatedAt)}`));
} else {
editorContainer.style.display = 'none';
placeholderContainer.style.display = 'flex';
}
};
const renderAll = () => { renderFolders(); renderNotes(); renderEditor(); };
// --- 아이템 찾기/수정 헬퍼 ---
const findItem = (id, type) => {
if (type === CONSTANTS.ITEM_TYPE.FOLDER) {
if (id === CONSTANTS.ALL_NOTES_ID) return { item: {id, name: '모든 노트', notes: [], noteOrder: []}, index: -1};
const index = state.folders.findIndex(f => f.id === id);
return { item: state.folders[index], index };
}
if (type === CONSTANTS.ITEM_TYPE.NOTE) {
const entry = state.noteMap.get(id);
if (entry) {
const { item: folder } = findItem(entry.folderId, CONSTANTS.ITEM_TYPE.FOLDER);
if (folder) {
const index = folder.notes.findIndex(n => n.id === id);
return { item: entry.note, index, folder };
}
}
}
return { item: null, index: -1, folder: null };
};
// --- 이벤트 핸들러 ---
const handleAddFolder = async () => {
const name = await showModal({ type: CONSTANTS.MODAL_TYPE.PROMPT, title: '📁 새 폴더 만들기', placeholder: '폴더 이름을 입력하세요' });
if (name && name.trim()) {
if (state.folders.some(f => f.name === name.trim())) {
showToast(`'${name.trim()}' 폴더는 이미 존재합니다.`, CONSTANTS.TOAST_TYPE.ERROR);
return;
}
const newFolder = { id: `folder-${Date.now()}`, name: name.trim(), notes: [], noteOrder: [] };
state.folders.push(newFolder);
setState({ activeFolderId: newFolder.id, activeNoteId: null, searchTerm: '' });
await saveData();
}
};
const handleAddNote = async () => {
if (!state.activeFolderId || state.activeFolderId === CONSTANTS.ALL_NOTES_ID) return;
const { item: activeFolder } = findItem(state.activeFolderId, CONSTANTS.ITEM_TYPE.FOLDER);
if (activeFolder) {
const now = Date.now();
const newNote = { id: `note-${now}`, title: "📝 새 노트", content: "", createdAt: now, updatedAt: now, isPinned: false };
activeFolder.notes.unshift(newNote);
activeFolder.noteOrder.unshift(newNote.id); // [개선] 수동 정렬 순서에 추가
_buildNoteMap();
setState({ activeNoteId: newNote.id, searchTerm: '' }, { notes: true, editor: true });
await saveData();
noteTitleInput.focus(); noteTitleInput.select();
}
};
const handleListClick = (e, type) => {
const li = e.target.closest('.item-list-entry');
if (!li) return;
const id = li.dataset.id;
if (e.target.closest('.pin-btn')) { handlePinNote(id); return; }
if (e.target.closest('.delete-item-btn')) { handleDelete(id, type); return; }
if (type === CONSTANTS.ITEM_TYPE.FOLDER && state.activeFolderId !== id) {
setState({ activeFolderId: id, activeNoteId: null, searchTerm: '' });
searchInput.value = '';
const { item: folder } = findItem(id, CONSTANTS.ITEM_TYPE.FOLDER);
let notesInFolder = [];
if (id === CONSTANTS.ALL_NOTES_ID) {
notesInFolder = state.folders.flatMap(f => f.notes);
} else if (folder) {
notesInFolder = folder.notes;
}
const sortedNotes = sortNotes(notesInFolder, state.noteSortOrder, folder?.noteOrder);
setState({ activeNoteId: sortedNotes?.[0]?.id || null }, { notes: true, editor: true });
} else if (type === CONSTANTS.ITEM_TYPE.NOTE && state.activeNoteId !== id) {
setState({ activeNoteId: id }, { notes: true, editor: true });
}
};
const handlePinNote = async (id) => {
const { item: note } = findItem(id, CONSTANTS.ITEM_TYPE.NOTE);
if (note) {
note.isPinned = !note.isPinned;
note.updatedAt = Date.now();
await saveData();
renderNotes();
}
};
const handleDelete = async (id, type) => {
const { item } = findItem(id, type);
if (!item) return;
let ok;
if (type === CONSTANTS.ITEM_TYPE.FOLDER) {
const message = `'${item.name}' 폴더와 안의 모든 노트(${item.notes.length}개)를 삭제합니다. 정말 삭제하시겠습니까?`;
ok = await showModal({ type: CONSTANTS.MODAL_TYPE.CONFIRM, title: '🗑️ 폴더 삭제', message, confirmText: '삭제' });
if (ok) {
const wasActive = state.activeFolderId === id;
state.folders = state.folders.filter(f => f.id !== id);
_buildNoteMap();
if (wasActive) {
setState({ activeFolderId: CONSTANTS.ALL_NOTES_ID, activeNoteId: null });
} else {
setState({}, { folders: true });
}
}
} else { // NOTE
ok = await showModal({ type: CONSTANTS.MODAL_TYPE.CONFIRM, title: '🗑️ 노트 삭제', message: `'${item.title}' 노트를 정말 삭제하시겠습니까?`, confirmText: '삭제' });
if (ok) {
const { folder } = findItem(id, CONSTANTS.ITEM_TYPE.NOTE);
if (folder) {
const sortedNotes = sortNotes(folder.notes, state.noteSortOrder, folder.noteOrder);
const deletedNoteIndexInSorted = sortedNotes.findIndex(n => n.id === id);
const nextNote = sortedNotes[deletedNoteIndexInSorted + 1] || sortedNotes[deletedNoteIndexInSorted - 1] || null;
// 실제 데이터에서 삭제
const indexInOriginal = folder.notes.findIndex(n => n.id === id);
if (indexInOriginal > -1) folder.notes.splice(indexInOriginal, 1);
// [개선] 수동 정렬 순서에서도 삭제
const indexInOrder = folder.noteOrder.indexOf(id);
if (indexInOrder > -1) folder.noteOrder.splice(indexInOrder, 1);
_buildNoteMap();
if (state.activeNoteId === id) {
setState({ activeNoteId: nextNote?.id || null }, { notes: true, editor: true });
} else {
setState({}, { notes: true });
}
}
}
}
if (ok) await saveData();
};
const handleRename = (e, type) => {
const nameSpan = e.target.closest('.item-name');
if (!nameSpan) return;
const li = nameSpan.closest('.item-list-entry'), id = li.dataset.id;
if (id === CONSTANTS.ALL_NOTES_ID) return;
const { item, folder } = findItem(id, type);
const originalName = (type === CONSTANTS.ITEM_TYPE.FOLDER) ? item.name : item.title;
li.draggable = false;
nameSpan.contentEditable = true;
nameSpan.focus();
document.execCommand('selectAll', false, null);
const endRename = async (newName) => {
nameSpan.contentEditable = false;
li.draggable = true;
nameSpan.removeEventListener('blur', onBlur);
nameSpan.removeEventListener('keydown', onKeydown);
newName = newName.trim();
let isDuplicate = false;
if (newName && newName !== originalName) {
if (type === CONSTANTS.ITEM_TYPE.FOLDER) {
isDuplicate = state.folders.some(f => f.id !== id && f.name === newName);
} else {
isDuplicate = folder.notes.some(n => n.id !== id && n.title === newName);
}
}
if (isDuplicate) {
showToast(`'${newName}' 이름이 이미 존재합니다.`, CONSTANTS.TOAST_TYPE.ERROR);
highlightText(nameSpan, originalName, '');
return;
}
if (newName && newName !== originalName) {
if (type === CONSTANTS.ITEM_TYPE.FOLDER) {
item.name = newName;
setState({}, { folders: true, notes: true });
} else {
item.title = newName;
item.updatedAt = Date.now();
setState({}, { notes: true, editor: true });
}
await saveData();
} else {
highlightText(nameSpan, originalName, '');
}
};
const onBlur = () => endRename(nameSpan.textContent);
const onKeydown = ev => {
if (ev.key === 'Enter') { ev.preventDefault(); nameSpan.blur(); }
if (ev.key === 'Escape') { highlightText(nameSpan, originalName, ''); nameSpan.blur(); }
};
nameSpan.addEventListener('blur', onBlur);
nameSpan.addEventListener('keydown', onKeydown);
};
const handleNoteUpdate = async (isForced = false) => {
if (isSaving && !isForced) return;
isSaving = true;
const { item: activeNote } = findItem(state.activeNoteId, CONSTANTS.ITEM_TYPE.NOTE);
if (activeNote) {
const newTitle = noteTitleInput.value, newContent = noteContentTextarea.value;
if (activeNote.title !== newTitle || activeNote.content !== newContent) {
activeNote.title = newTitle; activeNote.content = newContent; activeNote.updatedAt = Date.now();
const saveAndUpdate = async () => { await saveData(); renderEditor(); renderNotes(); };
if (isForced) { clearTimeout(debounceTimer); await saveAndUpdate(); }
else { debounce(saveAndUpdate, 500); }
}
}
isSaving = false;
};
// --- 드래그 앤 드롭 ---
const setupDragAndDrop = (listElement, type) => {
listElement.addEventListener('dragstart', e => {
const li = e.target.closest('.item-list-entry');
if (li) {
const id = li.dataset.id;
draggedItemInfo = { id, type };
if (type === CONSTANTS.ITEM_TYPE.NOTE) {
const { folder } = findItem(id, CONSTANTS.ITEM_TYPE.NOTE);
if (folder) draggedItemInfo.sourceFolderId = folder.id;
}
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => li.classList.add(CONSTANTS.CLASSES.DRAGGING), 0);
}
});
listElement.addEventListener('dragover', e => {
e.preventDefault();
const li = e.target.closest('.item-list-entry');
const isSelf = li && li.dataset.id === draggedItemInfo.id;
// [수정] '모든 노트' 뷰에서는 노트 순서 변경 UI(파란색 라인)가 보이지 않도록 수정
if (type === CONSTANTS.ITEM_TYPE.NOTE && state.activeFolderId === CONSTANTS.ALL_NOTES_ID) {
document.querySelector('.drag-over-indicator')?.remove();
e.dataTransfer.dropEffect = 'none';
return;
}
if (isSelf || !li) { e.dataTransfer.dropEffect = 'none'; return; }
e.dataTransfer.dropEffect = 'move';
document.querySelector('.drag-over-indicator')?.remove();
const indicator = document.createElement('li');
indicator.className = 'drag-over-indicator';
const rect = li.getBoundingClientRect();
if (e.clientY > rect.top + rect.height / 2) li.after(indicator);
else li.before(indicator);
});
listElement.addEventListener('drop', async e => {
e.preventDefault();
document.querySelector('.drag-over-indicator')?.remove();
const targetLi = e.target.closest('.item-list-entry');
if (!targetLi || !draggedItemInfo.id || targetLi.dataset.id === draggedItemInfo.id) return;
const draggedItemId = draggedItemInfo.id;
const targetItemId = targetLi.dataset.id;
let sourceList, draggedItem;
if (type === CONSTANTS.ITEM_TYPE.FOLDER) {
sourceList = state.folders;
} else {
const { folder } = findItem(draggedItemId, CONSTANTS.ITEM_TYPE.NOTE);
if (!folder) return;
sourceList = folder.notes;
}
const fromIndex = sourceList.findIndex(i => i.id === draggedItemId);
if(fromIndex === -1) return;
[draggedItem] = sourceList.splice(fromIndex, 1);
let toIndex = sourceList.findIndex(i => i.id === targetItemId);
const rect = targetLi.getBoundingClientRect();
const isAfter = e.clientY > rect.top + rect.height / 2;
if (isAfter) toIndex++;
sourceList.splice(toIndex, 0, draggedItem);
if (type === CONSTANTS.ITEM_TYPE.NOTE) {
const { folder } = findItem(draggedItemId, CONSTANTS.ITEM_TYPE.NOTE);
if (folder) {
// [개선] 수동 정렬 순서 업데이트 및 모드 변경
folder.noteOrder = folder.notes.map(n => n.id);
setState({ noteSortOrder: 'manual' });
}
} else {
setState({}, { all: true });
}
await saveData();
});
listElement.addEventListener('dragend', () => {
listElement.querySelector(`.${CONSTANTS.CLASSES.DRAGGING}`)?.classList.remove(CONSTANTS.CLASSES.DRAGGING);
document.querySelectorAll('.drag-over-indicator').forEach(el => el.remove());
draggedItemInfo = { id: null, type: null, sourceFolderId: null };
});
};
const setupNoteToFolderDrop = () => {
foldersPanel.addEventListener('dragover', e => {
if (draggedItemInfo.type !== CONSTANTS.ITEM_TYPE.NOTE) return;
e.preventDefault();
const targetFolderEl = e.target.closest('.item-list-entry');
document.querySelectorAll(`#folders-panel .${CONSTANTS.CLASSES.DROP_TARGET}`).forEach(el => el.classList.remove(CONSTANTS.CLASSES.DROP_TARGET));
const targetFolderId = targetFolderEl?.dataset.id;
if (targetFolderEl && targetFolderId !== CONSTANTS.ALL_NOTES_ID && targetFolderId !== draggedItemInfo.sourceFolderId) {
targetFolderEl.classList.add(CONSTANTS.CLASSES.DROP_TARGET);
e.dataTransfer.dropEffect = 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
});
foldersPanel.addEventListener('dragleave', e => {
if (!foldersPanel.contains(e.relatedTarget)) {
document.querySelectorAll(`#folders-panel .${CONSTANTS.CLASSES.DROP_TARGET}`).forEach(el => el.classList.remove(CONSTANTS.CLASSES.DROP_TARGET));
}
});
foldersPanel.addEventListener('drop', async e => {
e.preventDefault();
const targetFolderEl = e.target.closest(`.${CONSTANTS.CLASSES.DROP_TARGET}`);
targetFolderEl?.classList.remove(CONSTANTS.CLASSES.DROP_TARGET);
if (!targetFolderEl || !draggedItemInfo.id || draggedItemInfo.type !== CONSTANTS.ITEM_TYPE.NOTE) return;
const targetFolderId = targetFolderEl.dataset.id;
const { folder: sourceFolder, item: noteToMove, index: noteIndex } = findItem(draggedItemInfo.id, CONSTANTS.ITEM_TYPE.NOTE);
const { item: targetFolder } = findItem(targetFolderId, CONSTANTS.ITEM_TYPE.FOLDER);
if (sourceFolder && noteToMove && targetFolder && sourceFolder.id !== targetFolder.id) {
sourceFolder.notes.splice(noteIndex, 1);
// [개선] 수동 정렬 순서에서도 제거/추가
const orderIndex = sourceFolder.noteOrder.indexOf(noteToMove.id);
if (orderIndex > -1) sourceFolder.noteOrder.splice(orderIndex, 1);
noteToMove.updatedAt = Date.now();
targetFolder.notes.unshift(noteToMove);
targetFolder.noteOrder.unshift(noteToMove.id);
_buildNoteMap();
setState({ activeFolderId: targetFolderId, activeNoteId: noteToMove.id });
await saveData();
showToast(`✅ '${targetFolder.name}'(으)로 노트 이동 완료!`);
}
});
};
// --- 데이터 가져오기/내보내기 ---
const sanitizeData = data => {
if (!data || !Array.isArray(data.folders)) throw new Error("유효하지 않은 파일 구조입니다.");
const usedIds = new Set();
const getUniqueId = (prefix, id) => {
let finalId = String(id || `${prefix}-${Date.now()}`).slice(0, 50);
let counter = 1;
while (usedIds.has(finalId)) {
finalId = `${String(id).slice(0, 40)}-${counter++}`;
}
usedIds.add(finalId);
return finalId;
};
return {
folders: data.folders.map(f => {
const folderId = getUniqueId('folder', f.id);
const notes = Array.isArray(f.notes) ? f.notes.map(n => ({
id: getUniqueId('note', n.id),
title: String(n.title || '제목 없는 노트').slice(0, 200),
content: String(n.content || ''),
createdAt: Number(n.createdAt) || Date.now(),
updatedAt: Number(n.updatedAt) || Date.now(),
isPinned: !!n.isPinned,
})) : [];
return {
id: folderId,
name: String(f.name || '제목 없는 폴더').slice(0, 100),
notes: notes,
noteOrder: notes.map(n => n.id) // [개선] 가져올 때도 noteOrder 생성
};
})
};
};
// [BUG FIX] 한글 깨짐 방지를 위해 UTF-8 BOM 추가
const handleExport = () => {
try {
const dataToExport = { folders: state.folders };
const dataStr = JSON.stringify(dataToExport, null, 2);
const bom = new Uint8Array([0xEF, 0xBB, 0xBF]); // UTF-8 BOM
const blob = new Blob([bom, dataStr], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
chrome.downloads.download({
url: url,
filename: `new-tab-note-backup-${new Date().toISOString().slice(0,10)}.json`
}, () => {
URL.revokeObjectURL(url);
showToast('✅ 데이터 내보내기 성공!');
});
} catch (e) {
console.error("Export failed:", e);
showToast('❌ 데이터 내보내기 실패.', CONSTANTS.TOAST_TYPE.ERROR);
}
};
const handleImport = () => {
importFileInput.click();
};
importFileInput.onchange = async e => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async event => {
try {
const importedData = JSON.parse(event.target.result);
const sanitized = sanitizeData(importedData);
const ok = await showModal({ type: CONSTANTS.MODAL_TYPE.CONFIRM, title: '⚠️ 데이터 가져오기', message: '데이터를 가져오면 현재 모든 데이터가 교체됩니다. 계속하시겠습니까?', confirmText: '가져오기' });
if (ok) {
const newState = {
folders: sanitized.folders,
activeFolderId: sanitized.folders[0]?.id || CONSTANTS.ALL_NOTES_ID,
activeNoteId: sanitized.folders[0]?.notes[0]?.id || null,
searchTerm: '',
noteSortOrder: 'manual' // 가져온 데이터는 수동 정렬을 기본으로 함
};
setState(newState);
_buildNoteMap();
await saveData();
showToast('✅ 데이터를 성공적으로 가져왔습니다!');
}
} catch (err) {
showToast(`❌ 가져오기 실패: ${err.message}`, CONSTANTS.TOAST_TYPE.ERROR);
}
};
reader.readAsText(file);
e.target.value = '';
};
// --- 초기화 ---
const init = async () => {
folderList.addEventListener('click', e => handleListClick(e, CONSTANTS.ITEM_TYPE.FOLDER));
folderList.addEventListener('dblclick', e => handleRename(e, CONSTANTS.ITEM_TYPE.FOLDER));
noteList.addEventListener('click', e => handleListClick(e, CONSTANTS.ITEM_TYPE.NOTE));
noteList.addEventListener('dblclick', e => handleRename(e, CONSTANTS.ITEM_TYPE.NOTE));
setupDragAndDrop(folderList, CONSTANTS.ITEM_TYPE.FOLDER);
setupDragAndDrop(noteList, CONSTANTS.ITEM_TYPE.NOTE);
setupNoteToFolderDrop();
addFolderBtn.addEventListener('click', handleAddFolder);
addNoteBtn.addEventListener('click', handleAddNote);
noteTitleInput.addEventListener('input', () => handleNoteUpdate());
noteContentTextarea.addEventListener('input', () => handleNoteUpdate());
searchInput.addEventListener('input', e => {
debounce(() => setState({ searchTerm: e.target.value }, { notes: true }), 300);
});
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
setState({ searchTerm: '' }, { notes: true });
searchInput.focus();
});
noteSortSelect.addEventListener('change', e => {
setState({ noteSortOrder: e.target.value }, { notes: true });
});
exportBtn.addEventListener('click', handleExport);
importBtn.addEventListener('click', handleImport);
// [개선] 안정적인 데이터 저장을 위해 beforeunload 대신 visibilitychange 사용
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
handleNoteUpdate(true);
}
});
await loadData();
};
init();
});
newtab.html
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>새 탭 노트 v12.0 ✨</title>
<style>
@layer base, layout, components;
@layer base {
:root {
--bg-color: rgba(28, 28, 30, 0.7);
--blur-effect: blur(25px) saturate(180%);
--border-color: rgba(255, 255, 255, 0.125);
--divider-color: rgba(255, 255, 255, 0.2);
--font-color: #f5f5f7;
--font-color-dim: #a1a1a6;
--accent-color: #0A84FF;
--hover-bg-color: rgba(255, 255, 255, 0.1);
--active-bg-color: color-mix(in srgb, var(--accent-color) 40%, transparent);
--danger-color: #FF453A;
--success-color: #30D158;
--pin-color: #FFD60A;
--pin-bg-color: rgba(255, 214, 10, 0.1);
--highlight-bg-color: #FFD60A;
--highlight-font-color: #000;
--radius-md: 12px;
--radius-sm: 8px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", sans-serif;
background-image: url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?auto=format&fit=crop&w=2560');
background-size: cover;
background-position: center;
color: var(--font-color);
overflow: hidden;
height: 100vh;
}
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.4); }
}
@layer layout {
.container {
display: grid;
grid-template-columns: 240px 300px 1fr;
height: 100vh;
padding: 16px;
gap: 16px;
}
.panel {
background: var(--bg-color);
-webkit-backdrop-filter: var(--blur-effect);
backdrop-filter: var(--blur-effect);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
overflow: hidden;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.panel.drop-target {
border-color: var(--accent-color);
box-shadow: 0 0 10px var(--accent-color);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-block-end: 1px solid var(--border-color);
flex-shrink: 0;
gap: 8px;
}
.main-content { padding: 0; }
.editor-area { display: flex; flex-direction: column; height: 100%; }
#editor-footer {
flex-shrink: 0;
padding: 8px 24px;
border-block-start: 1px solid var(--border-color);
display: flex;
gap: 16px;
font-size: 12px;
color: var(--font-color-dim);
background: rgba(0,0,0,0.1);
}
}
@layer components {
.panel-header {
h2 { font-size: 16px; font-weight: 600; flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.button-group { display: flex; gap: 4px; }
}
.icon-button {
background: none; border: none; color: var(--font-color-dim); font-size: 20px;
cursor: pointer; padding: 4px; border-radius: var(--radius-sm); line-height: 1;
transition: all 0.2s ease;
&:hover { color: var(--font-color); background-color: var(--hover-bg-color); }
}
.pin-btn.pinned { color: var(--pin-color); }
.item-list { list-style: none; overflow-y: auto; padding: 8px; flex-grow: 1; }
.item-list-entry {
display: flex; align-items: center; gap: 8px; padding: 10px 12px; border-radius: var(--radius-sm);
cursor: pointer; transition: background-color 0.2s ease, opacity 0.2s ease; user-select: none;
&:hover { background-color: var(--hover-bg-color); }
&.active { background-color: var(--active-bg-color); color: white; font-weight: 500; }
&.dragging { opacity: 0.4; background: var(--active-bg-color); }
&.pinned { background-color: var(--pin-bg-color); }
}
.drag-over-indicator { height: 2px; background-color: var(--accent-color); margin-block: -1px; margin-inline: 8px; list-style: none; }
.pin-divider { height: 1px; background-color: var(--divider-color); margin: 6px 8px; list-style: none; }
.item-details { flex-grow: 1; overflow: hidden; }
.item-name {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 2px 0;
&[contenteditable="true"] { background-color: rgba(255,255,255,0.15); box-shadow: 0 0 0 2px var(--accent-color); outline: none; cursor: text; padding-inline: 4px; border-radius: 4px; }
mark { background-color: var(--highlight-bg-color); color: var(--highlight-font-color); border-radius: 3px; padding: 0 2px; }
}
.item-snippet {
font-size: 12px; color: var(--font-color-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 4px;
mark { background-color: transparent; color: var(--highlight-bg-color); font-weight: bold; padding: 0; }
}
.item-folder-tag {
font-size: 11px; color: var(--font-color-dim); background-color: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; white-space: nowrap;
}
.item-actions {
display: flex; align-items: center; opacity: 0; transition: opacity .2s ease; margin-inline-start: auto;
.item-list-entry:hover &, .item-list-entry.active & { opacity: 1; }
}
.delete-item-btn, .pin-btn { font-size: 16px; }
.delete-item-btn:hover { color: var(--danger-color); }
.notes-header-extra {
padding: 8px; border-block-end: 1px solid var(--border-color); flex-shrink: 0; display: flex; gap: 8px; align-items: center;
}
.search-container {
position: relative; flex-grow: 1;
#clear-search { display: none; }
&:has(#search-input:not(:placeholder-shown)) #clear-search { display: block; }
}
#search-input { width: 100%; background: rgba(0,0,0,0.2); border: 1px solid transparent; color: var(--font-color); padding: 8px 12px; border-radius: var(--radius-sm); font-size: 14px; outline: none; transition: all .2s ease;
&:focus { border-color: var(--accent-color); background: rgba(0,0,0,0.3); }
}
#clear-search { position: absolute; inset-inline-end: 8px; top: 50%; transform: translateY(-50%); background: 0; border: 0; color: var(--font-color-dim); font-size: 20px; cursor: pointer; }
#note-sort-select {
background: rgba(0,0,0,0.2); border: 1px solid transparent; color: var(--font-color); padding: 8px 4px; border-radius: var(--radius-sm); font-size: 13px; outline: none; flex-shrink: 0;
-webkit-appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23a1a1a6' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 4px center;
background-size: 16px;
padding-inline-end: 24px;
}
#note-title-input { background: transparent; border: none; color: var(--font-color); font-size: 28px; font-weight: 700; padding: 24px 24px 16px; width: 100%; outline: none; flex-shrink: 0; }
#note-content-textarea { background: transparent; border: none; color: var(--font-color); font-size: 16px; line-height: 1.7; width: 100%; flex-grow: 1; outline: none; resize: none; padding: 0 24px 24px; }
.placeholder { display: flex; flex-direction: column; gap: 10px; justify-content: center; align-items: center; height: 100%; font-size: 18px; color: var(--font-color-dim); text-align: center; }
.placeholder-button { background-color: var(--accent-color); color: white; border: none; padding: 10px 18px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 500; cursor: pointer; transition: opacity .2s ease; margin-top: 10px; }
.placeholder-button:hover { opacity: 0.8; }
.modal {
background: var(--bg-color); color: var(--font-color); border: 1px solid var(--border-color); border-radius: var(--radius-md);
padding: 24px; width: 90%; max-width: 400px; gap: 16px;
margin: auto;
&::backdrop { background: rgba(0,0,0,0.5); -webkit-backdrop-filter: var(--blur-effect); backdrop-filter: var(--blur-effect); }
h3 { font-size: 18px; font-weight: 600; text-align: center; }
p { font-size: 14px; color: var(--font-color-dim); text-align: center; line-height: 1.5; }
form { display: flex; flex-direction: column; gap: 16px; }
}
.modal-input { width: 100%; background: rgba(0,0,0,0.2); border: 1px solid transparent; color: var(--font-color); padding: 10px 12px; border-radius: var(--radius-sm); font-size: 16px; outline: none; transition: all .2s ease;
&:focus { border-color: var(--accent-color); }
}
.modal-buttons { display: flex; justify-content: center; gap: 12px; margin-top: 8px; }
.modal-button { padding: 10px 20px; border-radius: var(--radius-sm); border: none; font-size: 15px; font-weight: 500; cursor: pointer; transition: opacity .2s ease;
&:hover { opacity: 0.8; }
&.confirm { background-color: var(--accent-color); color: white; }
&.cancel { background-color: var(--hover-bg-color); color: var(--font-color); }
}
#toast-container { position: fixed; inset-block-start: 20px; inset-inline-end: 20px; z-index: 2000; display: flex; flex-direction: column; gap: 10px; }
.toast {
padding: 12px 20px; border-radius: var(--radius-sm); color: white; font-size: 14px; font-weight: 500;
opacity: 0; transform: translateX(100%); animation: slideIn-out 4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
&.success { background-color: var(--success-color); }
&.error { background-color: var(--danger-color); }
}
@keyframes slideIn-out { 0%, 100% { opacity: 0; transform: translateX(100%); } 10%, 90% { opacity: 1; transform: translateX(0); } }
}
</style>
</head>
<body>
<div id="toast-container"></div>
<div class="container">
<aside class="panel" id="folders-panel">
<header class="panel-header">
<h2>📁 폴더</h2>
<!-- [수정] 백업/복원 버튼 순서 변경 -->
<div class="button-group">
<button id="export-btn" class="icon-button" title="데이터 내보내기">📤</button>
<button id="import-btn" class="icon-button" title="데이터 가져오기">📥</button>
<button id="add-folder-btn" class="icon-button" title="새 폴더 추가">✨</button>
</div>
</header>
<ul id="folder-list" class="item-list"></ul>
<input type="file" id="import-file-input" accept=".json" style="display: none;">
</aside>
<nav class="panel">
<header class="panel-header">
<h2 id="notes-panel-title">📝 노트</h2>
<button id="add-note-btn" class="icon-button" title="새 노트 추가">➕</button>
</header>
<div class="notes-header-extra">
<div class="search-container">
<input type="search" id="search-input" placeholder="🔍 검색...">
<button id="clear-search" title="검색 지우기">×</button>
</div>
<!-- [수정] '수동 정렬' 옵션 추가 -->
<select id="note-sort-select" title="노트 정렬">
<option value="manual">수동 정렬</option>
<option value="updatedAt_desc">수정일 순 (최신)</option>
<option value="createdAt_desc">생성일 순 (최신)</option>
<option value="title_asc">제목 순 (ㄱ-ㅎ)</option>
<option value="title_desc">제목 순 (ㅎ-ㄱ)</option>
</select>
</div>
<ul id="note-list" class="item-list"></ul>
</nav>
<main class="panel main-content">
<div id="editor-container" class="editor-area" style="display: none;">
<input type="text" id="note-title-input" placeholder="🏷️ 제목을 입력하세요">
<textarea id="note-content-textarea" placeholder="✍️ 자유롭게 메모를 남겨보세요..."></textarea>
<div id="editor-footer"></div>
</div>
<div id="placeholder-container" class="placeholder">
<p style="font-size: 50px;">🚀</p>
<p>왼쪽에서 노트를 선택하거나<br>새로운 생각을 펼쳐보세요!</p>
</div>
</main>
</div>
<dialog id="modal" class="modal">
<h3 id="modal-title"></h3>
<p id="modal-message"></p>
<form id="modal-form" method="dialog">
<input type="text" id="modal-input" class="modal-input" style="display: none;">
<div id="modal-buttons" class="modal-buttons">
<button id="modal-cancel-btn" class="modal-button cancel" value="cancel"></button>
<button id="modal-confirm-btn" class="modal-button confirm" value="confirm"></button>
</div>
</form>
</dialog>
<template id="item-template">
<li class="item-list-entry" draggable="true">
<div class="item-details">
<span class="item-name"></span>
<div class="item-snippet" style="display: none;"></div>
</div>
<span class="item-folder-tag" style="display: none;"></span>
<div class="item-actions">
</div>
</li>
</template>
<script src="script.js"></script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment