diff --git a/src/handlers/dragDrop.js b/src/handlers/dragDrop.js new file mode 100644 index 0000000..d1a7614 --- /dev/null +++ b/src/handlers/dragDrop.js @@ -0,0 +1,219 @@ +let _grid, _gridContainer, _dragGhostEl; +let _state, _cb; + +let dragMode = null; +let dragSrcIdx = null; + +let autoScrollRAF = null; +let autoScrollSpeed = 0; +let lastScrollTs = null; + +export function init({ grid, gridContainer, dragGhostEl, state, callbacks }) { + _grid = grid; + _gridContainer = gridContainer; + _dragGhostEl = dragGhostEl; + _state = state; + _cb = callbacks; + + window.addEventListener('dragover', _onWindowDragOver); +} + +// 카드 생성 시 호출해 드래그 이벤트를 부착 +export function setupCard(card) { + card.addEventListener('dragstart', _onDragStart); + card.addEventListener('dragover', _onDragOver); + card.addEventListener('dragenter', _onDragEnter); + card.addEventListener('dragleave', _onDragLeave); + card.addEventListener('drop', _onDrop); + card.addEventListener('dragend', _onDragEnd); +} + +// ── window-level dragover: 고스트 위치 + 자동 스크롤 ── + +function _onWindowDragOver(e) { + if (!dragMode) return; + if (dragMode === 'multi') _moveGhost(e); + _trackScroll(e.clientY); +} + +// ── 카드 드래그 핸들러 ───────────────────────────── + +function _onDragStart(e) { + const idx = parseInt(this.dataset.idx); + + if (_state.selectedIdxs.has(idx) && _state.selectedIdxs.size > 1) { + dragMode = 'multi'; + dragSrcIdx = idx; + _state.selectedIdxs.forEach(i => { + const c = _getCardEl(i); + if (c) c.classList.add('multi-dragging'); + }); + _showGhost(e, `📦 ${_state.selectedIdxs.size}개 이동 중`); + const img = new Image(); + img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + e.dataTransfer.setDragImage(img, 0, 0); + } else { + dragMode = 'single'; + dragSrcIdx = idx; + this.classList.add('dragging'); + _state.selectedIdxs.clear(); + _cb.refreshCardSelection(); + } + + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); +} + +function _onDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; +} + +function _onDragEnter(e) { + e.preventDefault(); + const idx = parseInt(this.dataset.idx); + const isSrc = dragMode === 'single' ? idx === dragSrcIdx : _state.selectedIdxs.has(idx); + if (!isSrc) this.classList.add('drag-over'); +} + +function _onDragLeave() { + this.classList.remove('drag-over'); +} + +function _onDrop(e) { + e.preventDefault(); + this.classList.remove('drag-over'); + const targetIdx = parseInt(this.dataset.idx); + + if (dragMode === 'single') { + if (dragSrcIdx === null || dragSrcIdx === targetIdx) return; + _moveSingle(dragSrcIdx, targetIdx); + } else if (dragMode === 'multi') { + if (_state.selectedIdxs.has(targetIdx)) return; + _moveMulti(targetIdx); + } +} + +function _onDragEnd() { + _stopScroll(); + _hideGhost(); + document.querySelectorAll('.card').forEach(c => + c.classList.remove('dragging', 'multi-dragging', 'drag-over') + ); + dragMode = null; + dragSrcIdx = null; +} + +// ── 이동 로직 ───────────────────────────────────── + +function _moveSingle(srcIdx, targetIdx) { + const [moved] = _state.files.splice(srcIdx, 1); + _state.files.splice(targetIdx, 0, moved); + + const cards = Array.from(_grid.children); + const src = cards[srcIdx]; + const target = cards[targetIdx]; + if (srcIdx < targetIdx) { + _grid.insertBefore(src, target.nextSibling); + } else { + _grid.insertBefore(src, target); + } + Array.from(_grid.children).forEach((c, i) => { c.dataset.idx = i; }); + + _cb.updateNumberBadges(); + _cb.updateNewNameLabels(); +} + +function _moveMulti(targetIdx) { + const sorted = [..._state.selectedIdxs].sort((a, b) => a - b); + const movedFiles = sorted.map(i => _state.files[i]); + + const removedBefore = sorted.filter(i => i < targetIdx).length; + const adjustedTarget = targetIdx - removedBefore; + + for (let i = sorted.length - 1; i >= 0; i--) { + _state.files.splice(sorted[i], 1); + } + _state.files.splice(adjustedTarget, 0, ...movedFiles); + + const newSel = new Set(); + for (let i = 0; i < movedFiles.length; i++) newSel.add(adjustedTarget + i); + _state.selectedIdxs = newSel; + + // DOM 재정렬 (이미지 재로딩 없이) + const byPath = new Map(); + Array.from(_grid.children).forEach(c => byPath.set(c.dataset.filepath, c)); + _grid.innerHTML = ''; + _state.files.forEach((f, idx) => { + const c = byPath.get(f.path); + if (c) { c.dataset.idx = idx; _grid.appendChild(c); } + }); + + _cb.updateNumberBadges(); + _cb.updateNewNameLabels(); + _cb.refreshCardSelection(); + _cb.updateSelCount(); +} + +// ── 자동 스크롤 ─────────────────────────────────── + +function _trackScroll(clientY) { + const vh = window.innerHeight; + const topLine = vh * 0.1; + const botLine = vh * 0.9; + + if (clientY < topLine) { + const ratio = clientY / topLine; + _startScroll(-vh * (1 - 0.8 * ratio)); + } else if (clientY > botLine) { + const ratio = (clientY - botLine) / (vh * 0.1); + _startScroll(vh * (0.2 + 0.8 * ratio)); + } else { + _stopScroll(); + } +} + +function _startScroll(speed) { + autoScrollSpeed = speed; + if (!autoScrollRAF) { + lastScrollTs = performance.now(); + autoScrollRAF = requestAnimationFrame(_tickScroll); + } +} + +function _stopScroll() { + autoScrollSpeed = 0; + if (autoScrollRAF) { + cancelAnimationFrame(autoScrollRAF); + autoScrollRAF = null; + } +} + +function _tickScroll(now) { + if (!dragMode) { _stopScroll(); return; } + const dt = (now - lastScrollTs) / 1000; + lastScrollTs = now; + _gridContainer.scrollTop += autoScrollSpeed * dt; + autoScrollRAF = requestAnimationFrame(_tickScroll); +} + +// ── 드래그 고스트 ───────────────────────────────── + +function _showGhost(e, text) { + _dragGhostEl.textContent = text; + _dragGhostEl.style.display = 'block'; + _moveGhost(e); +} + +function _moveGhost(e) { + _dragGhostEl.style.left = (e.clientX + 16) + 'px'; + _dragGhostEl.style.top = (e.clientY - 14) + 'px'; +} + +function _hideGhost() { + _dragGhostEl.style.display = 'none'; +} + +function _getCardEl(idx) { + return _grid.querySelector(`.card[data-idx="${idx}"]`); +} diff --git a/src/handlers/pinchZoom.js b/src/handlers/pinchZoom.js new file mode 100644 index 0000000..41e9d0f --- /dev/null +++ b/src/handlers/pinchZoom.js @@ -0,0 +1,29 @@ +const THUMB_MIN = 150; +const THUMB_MAX = 400; + +let _thumbSizeHint; +let thumbSize = 250; +let thumbHintTimer = null; + +export function init({ gridContainer, thumbSizeHint }) { + _thumbSizeHint = thumbSizeHint; + + gridContainer.addEventListener('wheel', (e) => { + if (!e.ctrlKey) return; + e.preventDefault(); + setThumbSize(thumbSize + (-e.deltaY * 0.8)); + }, { passive: false }); +} + +export function setThumbSize(size) { + thumbSize = Math.round(Math.max(THUMB_MIN, Math.min(THUMB_MAX, size))); + document.documentElement.style.setProperty('--thumb-size', thumbSize + 'px'); + _showHint(thumbSize); +} + +function _showHint(size) { + _thumbSizeHint.textContent = `${size}px`; + _thumbSizeHint.classList.add('show'); + clearTimeout(thumbHintTimer); + thumbHintTimer = setTimeout(() => _thumbSizeHint.classList.remove('show'), 1000); +} diff --git a/src/handlers/selection.js b/src/handlers/selection.js new file mode 100644 index 0000000..4e9996c --- /dev/null +++ b/src/handlers/selection.js @@ -0,0 +1,90 @@ +let _rubberBand, _grid; +let _state, _cb; + +let rbActive = false; +let rbStartX = 0; +let rbStartY = 0; + +export function init({ gridContainer, rubberBand, grid, state, callbacks }) { + _rubberBand = rubberBand; + _grid = grid; + _state = state; + _cb = callbacks; + + gridContainer.addEventListener('mousedown', _onStart); + window.addEventListener('mousemove', _onMove); + window.addEventListener('mouseup', _onEnd); +} + +// 카드에 부착할 cmd+클릭 핸들러 (this = card element) +export function onCardClick(e) { + if (!e.metaKey) return; + e.stopPropagation(); + const idx = parseInt(this.dataset.idx); + if (_state.selectedIdxs.has(idx)) { + _state.selectedIdxs.delete(idx); + } else { + _state.selectedIdxs.add(idx); + } + _cb.refreshCardSelection(); + _cb.updateSelCount(); +} + +function _onStart(e) { + if (e.target.closest('.card')) return; + if (e.target.closest('button, input, label')) return; + if (e.button !== 0) return; + + rbActive = true; + rbStartX = e.clientX; + rbStartY = e.clientY; + + Object.assign(_rubberBand.style, { + display: 'block', + left: rbStartX + 'px', + top: rbStartY + 'px', + width: '0px', + height: '0px', + }); + + if (!e.shiftKey) { + _state.selectedIdxs.clear(); + _cb.refreshCardSelection(); + } +} + +function _onMove(e) { + if (!rbActive) return; + + const left = Math.min(rbStartX, e.clientX); + const top = Math.min(rbStartY, e.clientY); + const width = Math.abs(e.clientX - rbStartX); + const height = Math.abs(e.clientY - rbStartY); + + Object.assign(_rubberBand.style, { + left: left + 'px', top: top + 'px', + width: width + 'px', height: height + 'px', + }); + + const sel = { left, top, right: left + width, bottom: top + height }; + Array.from(_grid.children).forEach((card, i) => { + const r = card.getBoundingClientRect(); + const overlaps = !(r.right < sel.left || r.left > sel.right || + r.bottom < sel.top || r.top > sel.bottom); + if (overlaps) { + _state.selectedIdxs.add(i); + } else if (!e.shiftKey) { + _state.selectedIdxs.delete(i); + } + }); + + _cb.refreshCardSelection(); + _cb.updateSelCount(); +} + +function _onEnd() { + if (!rbActive) return; + rbActive = false; + _rubberBand.style.display = 'none'; + _cb.updateSelCount(); +} diff --git a/src/index.html b/src/index.html index 6f5018b..c0420f4 100644 --- a/src/index.html +++ b/src/index.html @@ -60,6 +60,6 @@
- +