사이드바에 트리 구조 폴더 기능 추가
This commit is contained in:
+48
-1
@@ -1,4 +1,16 @@
|
|||||||
const { ipcMain, dialog } = require('electron');
|
const { ipcMain, dialog, app, shell } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const configPath = path.join(app.getPath('userData'), 'config.json');
|
||||||
|
|
||||||
|
function readConfig() {
|
||||||
|
try { return JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch { return {}; }
|
||||||
|
}
|
||||||
|
function writeConfig(data) {
|
||||||
|
try { fs.writeFileSync(configPath, JSON.stringify(data, null, 2)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function register(mainWindow) {
|
function register(mainWindow) {
|
||||||
ipcMain.handle('select-folder', async () => {
|
ipcMain.handle('select-folder', async () => {
|
||||||
@@ -10,6 +22,41 @@ function register(mainWindow) {
|
|||||||
return result.filePaths[0];
|
return result.filePaths[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-home-dir', () => os.homedir());
|
||||||
|
|
||||||
|
ipcMain.handle('get-last-folder', () => {
|
||||||
|
const cfg = readConfig();
|
||||||
|
if (cfg.lastFolder) {
|
||||||
|
try { fs.accessSync(cfg.lastFolder); return cfg.lastFolder; } catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('set-last-folder', (_e, folderPath) => {
|
||||||
|
const cfg = readConfig();
|
||||||
|
cfg.lastFolder = folderPath;
|
||||||
|
writeConfig(cfg);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('list-directories', (_e, folderPath) => {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||||
|
return entries
|
||||||
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
||||||
|
.map(e => {
|
||||||
|
const fullPath = path.join(folderPath, e.name);
|
||||||
|
let hasChildren = false;
|
||||||
|
try {
|
||||||
|
hasChildren = fs.readdirSync(fullPath, { withFileTypes: true })
|
||||||
|
.some(c => c.isDirectory() && !c.name.startsWith('.'));
|
||||||
|
} catch {}
|
||||||
|
return { name: e.name, path: fullPath, hasChildren };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
} catch { return []; }
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('reveal-folder', (_e, folderPath) => shell.openPath(folderPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { register };
|
module.exports = { register };
|
||||||
@@ -6,4 +6,9 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
readImage: (path) => ipcRenderer.invoke('read-image', path),
|
readImage: (path) => ipcRenderer.invoke('read-image', path),
|
||||||
renameFiles: (args) => ipcRenderer.invoke('rename-files', args),
|
renameFiles: (args) => ipcRenderer.invoke('rename-files', args),
|
||||||
onFullscreenChange: (callback) => ipcRenderer.on('fullscreen-change', (_e, v) => callback(v)),
|
onFullscreenChange: (callback) => ipcRenderer.on('fullscreen-change', (_e, v) => callback(v)),
|
||||||
|
getHomeDir: () => ipcRenderer.invoke('get-home-dir'),
|
||||||
|
getLastFolder: () => ipcRenderer.invoke('get-last-folder'),
|
||||||
|
setLastFolder: (p) => ipcRenderer.invoke('set-last-folder', p),
|
||||||
|
listDirectories: (p) => ipcRenderer.invoke('list-directories', p),
|
||||||
|
revealFolder: (p) => ipcRenderer.invoke('reveal-folder', p),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Directory tree for the sidebar file-explorer pane.
|
||||||
|
// Lazy-loads children when a node is expanded. Tracks one selected path.
|
||||||
|
|
||||||
|
let _onSelect = null; // callback(folderPath)
|
||||||
|
let _selectedPath = null;
|
||||||
|
|
||||||
|
const ICON_CLOSED = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 3.5C1 2.67 1.67 2 2.5 2H5.38l1.5 1.5H11.5C12.33 3.5 13 4.17 13 5v5.5C13 11.33 12.33 12 11.5 12h-9C1.67 12 1 11.33 1 10.5V3.5Z" fill="#7c6af7" fill-opacity=".75"/><path d="M1 5.5h12v5C13 11.33 12.33 12 11.5 12h-9C1.67 12 1 11.33 1 10.5V5.5Z" fill="#9180ff" fill-opacity=".9"/></svg>`;
|
||||||
|
const ICON_OPEN = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 3.5C1 2.67 1.67 2 2.5 2H5.38l1.5 1.5H11.5C12.33 3.5 13 4.17 13 5v5.5C13 11.33 12.33 12 11.5 12h-9C1.67 12 1 11.33 1 10.5V3.5Z" fill="#c9b8ff" fill-opacity=".7"/><path d="M1 5.5h12v5C13 11.33 12.33 12 11.5 12h-9C1.67 12 1 11.33 1 10.5V5.5Z" fill="#e0d5ff" fill-opacity=".85"/></svg>`;
|
||||||
|
const ICON_HOME = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 1.5L1 6.5V12.5H5.5V9H8.5V12.5H13V6.5L7 1.5Z" fill="#6ac8f7" fill-opacity=".9"/></svg>`;
|
||||||
|
|
||||||
|
export function init(container, onSelect) {
|
||||||
|
_onSelect = onSelect;
|
||||||
|
container.innerHTML = '';
|
||||||
|
container.classList.add('tree-root');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTree(rootPath, selectedPath) {
|
||||||
|
_selectedPath = selectedPath;
|
||||||
|
const container = document.querySelector('.file-explorer');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
const rootNode = createNode({
|
||||||
|
name: rootPath.split('/').pop() || rootPath,
|
||||||
|
path: rootPath,
|
||||||
|
hasChildren: true,
|
||||||
|
isRoot: true,
|
||||||
|
});
|
||||||
|
container.appendChild(rootNode);
|
||||||
|
await expandNode(rootNode, selectedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNode({ name, path: nodePath, hasChildren, isRoot = false }) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'tree-node';
|
||||||
|
wrap.dataset.path = nodePath;
|
||||||
|
wrap.dataset.expanded = 'false';
|
||||||
|
wrap.dataset.hasChildren = hasChildren ? 'true' : 'false';
|
||||||
|
wrap.dataset.isRoot = isRoot ? 'true' : 'false';
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'tree-row';
|
||||||
|
|
||||||
|
const chevron = document.createElement('span');
|
||||||
|
chevron.className = 'tree-chevron';
|
||||||
|
chevron.innerHTML = hasChildren
|
||||||
|
? `<svg width="10" height="10" viewBox="0 0 10 10"><path d="M3 2l4 3-4 3" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const icon = document.createElement('span');
|
||||||
|
icon.className = 'tree-icon';
|
||||||
|
icon.innerHTML = isRoot ? ICON_HOME : ICON_CLOSED;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'tree-label';
|
||||||
|
label.textContent = name;
|
||||||
|
label.title = nodePath;
|
||||||
|
|
||||||
|
row.appendChild(chevron);
|
||||||
|
row.appendChild(icon);
|
||||||
|
row.appendChild(label);
|
||||||
|
wrap.appendChild(row);
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
const children = document.createElement('div');
|
||||||
|
children.className = 'tree-children';
|
||||||
|
wrap.appendChild(children);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectNode(wrap);
|
||||||
|
if (hasChildren) toggleNode(wrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNode(nodeEl) {
|
||||||
|
const path = nodeEl.dataset.path;
|
||||||
|
document.querySelectorAll('.tree-row.active').forEach(r => r.classList.remove('active'));
|
||||||
|
nodeEl.querySelector(':scope > .tree-row').classList.add('active');
|
||||||
|
_selectedPath = path;
|
||||||
|
if (_onSelect) _onSelect(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleNode(nodeEl) {
|
||||||
|
const expanded = nodeEl.dataset.expanded === 'true';
|
||||||
|
if (expanded) {
|
||||||
|
collapseNode(nodeEl);
|
||||||
|
} else {
|
||||||
|
await expandNode(nodeEl, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseNode(nodeEl) {
|
||||||
|
nodeEl.dataset.expanded = 'false';
|
||||||
|
const row = nodeEl.querySelector(':scope > .tree-row');
|
||||||
|
row.querySelector('.tree-chevron').classList.remove('open');
|
||||||
|
row.querySelector('.tree-icon').innerHTML = ICON_CLOSED;
|
||||||
|
const children = nodeEl.querySelector(':scope > .tree-children');
|
||||||
|
if (children) children.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expandNode(nodeEl, autoExpandPath) {
|
||||||
|
nodeEl.dataset.expanded = 'true';
|
||||||
|
const row = nodeEl.querySelector(':scope > .tree-row');
|
||||||
|
row.querySelector('.tree-chevron').classList.add('open');
|
||||||
|
row.querySelector('.tree-icon').innerHTML = nodeEl.dataset.isRoot === 'true' ? ICON_HOME : ICON_OPEN;
|
||||||
|
|
||||||
|
const childrenEl = nodeEl.querySelector(':scope > .tree-children');
|
||||||
|
if (!childrenEl) return;
|
||||||
|
childrenEl.style.display = '';
|
||||||
|
|
||||||
|
// Load children if not yet loaded
|
||||||
|
if (!childrenEl.dataset.loaded) {
|
||||||
|
childrenEl.dataset.loaded = 'true';
|
||||||
|
const dirs = await window.api.listDirectories(nodeEl.dataset.path);
|
||||||
|
dirs.forEach(dir => {
|
||||||
|
const child = createNode(dir);
|
||||||
|
childrenEl.appendChild(child);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-expand path to target
|
||||||
|
if (autoExpandPath && autoExpandPath !== nodeEl.dataset.path) {
|
||||||
|
const childNodes = childrenEl.querySelectorAll(':scope > .tree-node');
|
||||||
|
for (const child of childNodes) {
|
||||||
|
if (autoExpandPath === child.dataset.path || autoExpandPath.startsWith(child.dataset.path + '/')) {
|
||||||
|
await expandNode(child, autoExpandPath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoExpandPath === nodeEl.dataset.path) {
|
||||||
|
selectNode(nodeEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSelectedPath(folderPath) {
|
||||||
|
_selectedPath = folderPath;
|
||||||
|
document.querySelectorAll('.tree-row.active').forEach(r => r.classList.remove('active'));
|
||||||
|
const node = document.querySelector(`.tree-node[data-path="${CSS.escape(folderPath)}"]`);
|
||||||
|
if (node) node.querySelector(':scope > .tree-row').classList.add('active');
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import * as pinchZoom from './handlers/pinch-zoom.js';
|
|||||||
import * as selection from './handlers/selection.js';
|
import * as selection from './handlers/selection.js';
|
||||||
import * as dragDrop from './handlers/drag-drop.js';
|
import * as dragDrop from './handlers/drag-drop.js';
|
||||||
import * as hoverPreview from './handlers/image-preview.js';
|
import * as hoverPreview from './handlers/image-preview.js';
|
||||||
|
import * as fileExplorer from './handlers/file-explorer.js';
|
||||||
|
|
||||||
let _cardObserver = null;
|
let _cardObserver = null;
|
||||||
|
|
||||||
@@ -43,10 +44,24 @@ selection.init({ gridContainer, rubberBand, grid, state, callbacks });
|
|||||||
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks });
|
dragDrop.init({ grid, gridContainer, dragGhostEl, state, callbacks });
|
||||||
hoverPreview.init();
|
hoverPreview.init();
|
||||||
|
|
||||||
|
const fileExplorerContainer = document.querySelector('.file-explorer');
|
||||||
|
fileExplorer.init(fileExplorerContainer, loadFolder);
|
||||||
|
|
||||||
window.api.onFullscreenChange((isFullscreen) => {
|
window.api.onFullscreenChange((isFullscreen) => {
|
||||||
document.body.classList.toggle('fullscreen', isFullscreen);
|
document.body.classList.toggle('fullscreen', isFullscreen);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 앱 시작 시 마지막 폴더 또는 홈 디렉토리 로드
|
||||||
|
(async () => {
|
||||||
|
const [lastFolder, homeDir] = await Promise.all([
|
||||||
|
window.api.getLastFolder(),
|
||||||
|
window.api.getHomeDir(),
|
||||||
|
]);
|
||||||
|
const target = lastFolder || homeDir;
|
||||||
|
await fileExplorer.loadTree(homeDir, target);
|
||||||
|
await loadFolder(target);
|
||||||
|
})();
|
||||||
|
|
||||||
// ── 버튼 이벤트 ──────────────────────────────────
|
// ── 버튼 이벤트 ──────────────────────────────────
|
||||||
inputPrefix.addEventListener('input', updatePreview);
|
inputPrefix.addEventListener('input', updatePreview);
|
||||||
inputDigits.addEventListener('input', updatePreview);
|
inputDigits.addEventListener('input', updatePreview);
|
||||||
@@ -65,20 +80,45 @@ btnSortName.addEventListener('click', () => sortFiles('name'));
|
|||||||
btnSortDate.addEventListener('click', () => sortFiles('date'));
|
btnSortDate.addEventListener('click', () => sortFiles('date'));
|
||||||
btnRename.addEventListener('click', doRename);
|
btnRename.addEventListener('click', doRename);
|
||||||
|
|
||||||
// ── 폴더 열기 ────────────────────────────────────
|
// ── 폴더 열기 (다이얼로그) ───────────────────────
|
||||||
async function openFolder() {
|
async function openFolder() {
|
||||||
const folder = await window.api.selectFolder();
|
const folder = await window.api.selectFolder();
|
||||||
|
if (!folder) return;
|
||||||
|
const homeDir = await window.api.getHomeDir();
|
||||||
|
// 선택한 폴더가 홈 디렉토리 하위인지 확인해서 트리 업데이트
|
||||||
|
if (folder.startsWith(homeDir)) {
|
||||||
|
await fileExplorer.loadTree(homeDir, folder);
|
||||||
|
} else {
|
||||||
|
await fileExplorer.loadTree(folder, folder);
|
||||||
|
}
|
||||||
|
await loadFolder(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 폴더 로드 (트리 클릭 또는 다이얼로그 공통) ──
|
||||||
|
async function loadFolder(folder) {
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
state.currentFolder = folder;
|
state.currentFolder = folder;
|
||||||
dirPath.textContent = folder;
|
dirPath.textContent = folder;
|
||||||
|
await window.api.setLastFolder(folder);
|
||||||
|
fileExplorer.setSelectedPath(folder);
|
||||||
|
|
||||||
showLoading('이미지 목록 불러오는 중...');
|
showLoading('이미지 목록 불러오는 중...');
|
||||||
const list = await window.api.listImages(folder);
|
const list = await window.api.listImages(folder);
|
||||||
hideLoading();
|
hideLoading();
|
||||||
|
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
|
state.files.length = 0;
|
||||||
|
state.selectedIdxs.clear();
|
||||||
|
grid.innerHTML = '';
|
||||||
|
grid.style.display = 'none';
|
||||||
|
emptyState.style.display = '';
|
||||||
|
fileCount.textContent = '0개 파일';
|
||||||
|
updateSelCount();
|
||||||
|
btnRename.disabled = true;
|
||||||
setStatus('이미지 파일 없음', false);
|
setStatus('이미지 파일 없음', false);
|
||||||
showToast('⚠️ 이미지 파일이 없습니다', '#f76a6a', '#0a0a0a');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.files.length = 0;
|
state.files.length = 0;
|
||||||
state.files.push(...list);
|
state.files.push(...list);
|
||||||
state.selectedIdxs.clear();
|
state.selectedIdxs.clear();
|
||||||
|
|||||||
@@ -423,6 +423,55 @@ footer span { font-size: 11px; color: var(--muted); }
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 파일 탐색기 트리 ── */
|
||||||
|
.tree-root { padding: 6px 0; }
|
||||||
|
|
||||||
|
.tree-node { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.tree-row {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
padding: 3px 8px 3px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12.5px;
|
||||||
|
transition: background .1s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tree-row:hover { background: rgba(255,255,255,.05); }
|
||||||
|
.tree-row.active {
|
||||||
|
background: rgba(124,106,247,.18);
|
||||||
|
color: #d0c8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-chevron {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0; color: var(--muted);
|
||||||
|
transition: transform .15s;
|
||||||
|
}
|
||||||
|
.tree-chevron.open { transform: rotate(90deg); }
|
||||||
|
.tree-chevron svg { display: block; }
|
||||||
|
|
||||||
|
.tree-icon {
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tree-icon svg { display: block; }
|
||||||
|
|
||||||
|
.tree-label {
|
||||||
|
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-children {
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 1px solid rgba(255,255,255,.05);
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 토스트 ── */
|
/* ── 토스트 ── */
|
||||||
#toast {
|
#toast {
|
||||||
position: fixed; bottom: 40px; left: 50%;
|
position: fixed; bottom: 40px; left: 50%;
|
||||||
|
|||||||
Reference in New Issue
Block a user