mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-24 19:48:35 +09:00
feat: release v2.0 - visual linker, instant edit, and ux improvements
This commit is contained in:
+61
-17
@@ -9,6 +9,8 @@ import { ComposerManager } from './js/components/ComposerManager.js';
|
||||
import { CalendarManager } from './js/components/CalendarManager.js';
|
||||
import { Visualizer } from './js/components/Visualizer.js';
|
||||
import { HeatmapManager } from './js/components/HeatmapManager.js';
|
||||
import { ThemeManager } from './js/components/ThemeManager.js';
|
||||
import { VisualLinker } from './js/components/VisualLinker.js';
|
||||
import { DrawerManager } from './js/components/DrawerManager.js';
|
||||
import { CategoryManager } from './js/components/CategoryManager.js';
|
||||
import { ModalManager } from './js/components/ModalManager.js';
|
||||
@@ -16,20 +18,6 @@ import { I18nManager } from './js/utils/I18nManager.js';
|
||||
import { Constants } from './js/utils/Constants.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// --- 🔹 Initialization ---
|
||||
await UI.initSettings(); // ⭐ i18n 및 테마 로딩 완료까지 최우선 대기
|
||||
EditorManager.init('#editor');
|
||||
|
||||
// 작성기 초기화 (저장 성공 시 데이터 새로고침 콜백 등록)
|
||||
ComposerManager.init(() => AppService.refreshData(updateSidebarCallback));
|
||||
|
||||
// 히트맵 초기화
|
||||
HeatmapManager.init('heatmapContainer', (date) => {
|
||||
AppService.setFilter({ date }, updateSidebarCallback);
|
||||
});
|
||||
DrawerManager.init();
|
||||
CategoryManager.init(() => AppService.refreshData(updateSidebarCallback));
|
||||
Visualizer.init('graphContainer');
|
||||
UI.initSidebarToggle();
|
||||
|
||||
// --- 🔹 Callbacks ---
|
||||
@@ -45,7 +33,10 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 달력 초기화
|
||||
// --- 🔹 Initialization (After callbacks are defined) ---
|
||||
await UI.initSettings();
|
||||
|
||||
// 달력 초기화 (I18n 로드 후 처리)
|
||||
CalendarManager.init('calendarContainer', (date) => {
|
||||
AppService.setFilter({ date }, updateSidebarCallback);
|
||||
});
|
||||
@@ -55,6 +46,28 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
AppService.loadMore(updateSidebarCallback);
|
||||
});
|
||||
|
||||
// 작성기 콜백
|
||||
const onSaveSuccess = () => AppService.refreshData(updateSidebarCallback);
|
||||
|
||||
// 에디터 초기화 (Ctrl+Enter 연동)
|
||||
EditorManager.init('#editor', () => {
|
||||
if (ComposerManager.DOM.composer.style.display === 'block') {
|
||||
ComposerManager.handleSave(onSaveSuccess);
|
||||
}
|
||||
});
|
||||
|
||||
// 작성기 초기화
|
||||
ComposerManager.init(onSaveSuccess);
|
||||
|
||||
// 히트맵 초기화
|
||||
HeatmapManager.init('heatmapContainer', (date) => {
|
||||
AppService.setFilter({ date }, updateSidebarCallback);
|
||||
});
|
||||
|
||||
DrawerManager.init();
|
||||
CategoryManager.init(onSaveSuccess);
|
||||
Visualizer.init('graphContainer');
|
||||
|
||||
// 드래그 앤 드롭 파일 탐지
|
||||
EditorManager.bindDropEvent('.composer-wrapper', (shouldOpen) => {
|
||||
if (shouldOpen && ComposerManager.DOM.composer.style.display === 'none') {
|
||||
@@ -70,8 +83,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
},
|
||||
onDelete: async (id) => {
|
||||
if (confirm(I18nManager.t('msg_delete_confirm'))) {
|
||||
await API.deleteMemo(id);
|
||||
AppService.refreshData(updateSidebarCallback);
|
||||
try {
|
||||
await API.deleteMemo(id);
|
||||
AppService.refreshData(updateSidebarCallback);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
onAI: async (id) => {
|
||||
@@ -151,6 +168,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
// --- 🔹 Global Shortcuts (Comprehensive Shift to Ctrl-based System) ---
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!e.key) return;
|
||||
const isCtrl = e.ctrlKey || e.metaKey;
|
||||
const isAlt = e.altKey;
|
||||
const key = e.key.toLowerCase();
|
||||
@@ -211,8 +229,34 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
ComposerManager.toggleCategoryBySlot(slotIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 'e': 즉시 수정 (마우스 오버 상태일 때)
|
||||
if (key === 'e' && !isCtrl && !isAlt && !e.shiftKey) {
|
||||
const isInput = ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) ||
|
||||
document.activeElement.isContentEditable;
|
||||
if (!isInput && window.hoveredMemoId) {
|
||||
e.preventDefault();
|
||||
window.memoEventHandlers.onEdit(window.hoveredMemoId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- 🔹 App Start ---
|
||||
AppService.refreshData(updateSidebarCallback);
|
||||
VisualLinker.init(); // 💡 연결 도구 초기화
|
||||
|
||||
// 💡 전역 취소 리스너 (시각적 연결용)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && VisualLinker.state.isActive) {
|
||||
VisualLinker.cancel();
|
||||
}
|
||||
});
|
||||
window.addEventListener('contextmenu', (e) => {
|
||||
if (VisualLinker.state.isActive) {
|
||||
e.preventDefault();
|
||||
VisualLinker.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
// 💡 전역 클릭 슈퍼 디버깅 (어디가 클릭되는지 추적)
|
||||
});
|
||||
|
||||
@@ -102,3 +102,78 @@
|
||||
color: white;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Checkbox Visibility Enhancement */
|
||||
.memo-content input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
margin-top: -2px;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.memo-content input[type="checkbox"]:checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(56, 189, 248, 0.4);
|
||||
}
|
||||
|
||||
.memo-content input[type="checkbox"]:checked::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family: Arial, sans-serif;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #0f172a;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.memo-content input[type="checkbox"]:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
|
||||
/* 완료된 리스트 항목 스타일 */
|
||||
.memo-content li:has(input[type="checkbox"]:checked) {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.memo-content ul {
|
||||
list-style-type: none;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.copy-id-btn:hover {
|
||||
color: var(--accent) !important;
|
||||
text-shadow: 0 0 8px rgba(56, 189, 248, 0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 💡 시각적 연결 모드 스타일 */
|
||||
body.linker-active {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
body.linker-active .copy-id-btn {
|
||||
color: var(--accent) !important;
|
||||
animation: pulse-border 1.5s infinite;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
0% { transform: scale(1); text-shadow: 0 0 0px var(--accent); }
|
||||
50% { transform: scale(1.2); text-shadow: 0 0 10px var(--accent); }
|
||||
100% { transform: scale(1); text-shadow: 0 0 0px var(--accent); }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ export const API = {
|
||||
});
|
||||
return await this.request(`/api/memos?${params.toString()}`);
|
||||
},
|
||||
async fetchMemo(id) {
|
||||
return await this.request(`/api/memos/${id}?_t=${Date.now()}`);
|
||||
},
|
||||
async fetchHeatmapData(days = 365) {
|
||||
return await this.request(`/api/stats/heatmap?days=${days}&_t=${Date.now()}`);
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ComposerManager = {
|
||||
password: document.getElementById('memoPassword'),
|
||||
foldBtn: document.getElementById('foldBtn'),
|
||||
discardBtn: document.getElementById('discardBtn'),
|
||||
deleteBtn: document.getElementById('deleteMemoBtn'), // NEW
|
||||
categoryBar: document.getElementById('composerCategoryBar')
|
||||
};
|
||||
|
||||
@@ -40,13 +41,28 @@ export const ComposerManager = {
|
||||
this.DOM.foldBtn.onclick = () => this.close();
|
||||
|
||||
this.DOM.discardBtn.onclick = async () => {
|
||||
if (confirm(I18nManager.t('msg_confirm_discard'))) {
|
||||
await EditorManager.cleanupSessionFiles();
|
||||
this.clear();
|
||||
this.close();
|
||||
const isEditing = !!this.DOM.id.value;
|
||||
// 💡 기존 메모 수정 중일 때는 확인 없이 바로 닫기
|
||||
// 💡 새 메모 작성 중일 때만 파일 정리 여부 묻기
|
||||
if (isEditing || confirm(I18nManager.t('msg_confirm_discard'))) {
|
||||
this.forceClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 💡 에디터 내 실제 삭제 버튼
|
||||
if (this.DOM.deleteBtn) {
|
||||
this.DOM.deleteBtn.onclick = async () => {
|
||||
const id = this.DOM.id.value;
|
||||
if (!id) return;
|
||||
if (confirm(I18nManager.t('msg_delete_confirm'))) {
|
||||
await API.deleteMemo(id);
|
||||
if (onSaveSuccess) onSaveSuccess();
|
||||
this.clear();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
this.DOM.composer.onsubmit = (e) => {
|
||||
e.preventDefault();
|
||||
this.handleSave(onSaveSuccess);
|
||||
@@ -87,6 +103,7 @@ export const ComposerManager = {
|
||||
|
||||
this.DOM.composer.style.display = 'block';
|
||||
this.DOM.trigger.style.display = 'none';
|
||||
if (this.DOM.deleteBtn) this.DOM.deleteBtn.style.display = 'none'; // 새 메모에선 숨김
|
||||
this.renderCategoryChips(); // 💡 초기화 후 칩 렌더링
|
||||
this.DOM.title.focus();
|
||||
},
|
||||
@@ -112,6 +129,7 @@ export const ComposerManager = {
|
||||
|
||||
this.DOM.composer.style.display = 'block';
|
||||
this.DOM.trigger.style.display = 'none';
|
||||
if (this.DOM.deleteBtn) this.DOM.deleteBtn.style.display = 'block'; // 수정 시에만 보임
|
||||
this.renderCategoryChips(); // 💡 렌더링
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
},
|
||||
@@ -147,6 +165,12 @@ export const ComposerManager = {
|
||||
this.DOM.trigger.style.display = 'block';
|
||||
},
|
||||
|
||||
forceClose() {
|
||||
EditorManager.cleanupSessionFiles().catch(e => console.error(e));
|
||||
this.clear();
|
||||
this.close();
|
||||
},
|
||||
|
||||
clear() {
|
||||
this.DOM.id.value = '';
|
||||
this.DOM.title.value = '';
|
||||
|
||||
@@ -81,14 +81,14 @@ export function createMemoCardHtml(memo, isDone) {
|
||||
const isLocked = memo.is_encrypted && (!htmlContent || htmlContent.includes('encrypted-block'));
|
||||
const actionsHtml = isLocked ? '' : `
|
||||
<div class="memo-actions">
|
||||
<button class="action-btn toggle-pin" data-id="${memo.id}" title="${I18nManager.t('title_pin')}">${memo.is_pinned ? '⭐' : '☆'}</button>
|
||||
<button class="action-btn toggle-status" data-id="${memo.id}" title="${isDone ? I18nManager.t('title_undo') : I18nManager.t('title_done')}">${isDone ? '↩️' : '✅'}</button>
|
||||
${!isDone ? `<button class="action-btn ai-btn" data-id="${memo.id}" title="${I18nManager.t('title_ai')}">🪄</button>` : ''}
|
||||
<button class="action-btn edit-btn" data-id="${memo.id}" title="${I18nManager.t('title_edit')}">✏️</button>
|
||||
<button class="action-btn delete-btn" data-id="${memo.id}" title="${I18nManager.t('title_delete')}">🗑️</button>
|
||||
<button class="action-btn toggle-pin" draggable="false" data-id="${memo.id}" title="${I18nManager.t('title_pin')}">${memo.is_pinned ? '⭐' : '☆'}</button>
|
||||
<button class="action-btn toggle-status" draggable="false" data-id="${memo.id}" title="${isDone ? I18nManager.t('title_undo') : I18nManager.t('title_done')}">${isDone ? '↩️' : '✅'}</button>
|
||||
${!isDone ? `<button class="action-btn ai-btn" draggable="false" data-id="${memo.id}" title="${I18nManager.t('title_ai')}">🪄</button>` : ''}
|
||||
<button class="action-btn edit-btn" draggable="false" data-id="${memo.id}" title="${I18nManager.t('title_edit')}">✏️</button>
|
||||
<button class="action-btn delete-btn" draggable="false" data-id="${memo.id}" title="${I18nManager.t('title_delete')}">🗑️</button>
|
||||
</div>
|
||||
`;
|
||||
const idBadge = `<div style="position:absolute; top:10px; right:12px; color:rgba(255,255,255,0.15); font-size:10px; font-weight:900;">#${memo.id}</div>`;
|
||||
const idBadge = `<div class="copy-id-btn" draggable="false" data-id="${memo.id}" title="${I18nManager.t('tooltip_id_copy').replace('[[#ID]]', `[[#${memo.id}]]`)}" style="position:absolute; top:10px; right:12px; color:rgba(255,255,255,0.15); font-size:10px; font-weight:900; cursor:pointer; z-index:10;">#${memo.id}</div>`;
|
||||
|
||||
return {
|
||||
className: cardClass,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 메모 간 시각적 연결(Node-to-Node Linking) 관리 모듈
|
||||
*/
|
||||
import { API } from '../api.js';
|
||||
import { I18nManager } from '../utils/I18nManager.js';
|
||||
import { AppService } from '../AppService.js';
|
||||
|
||||
export const VisualLinker = {
|
||||
state: {
|
||||
isActive: false,
|
||||
sourceId: null,
|
||||
sourceElement: null,
|
||||
startX: 0,
|
||||
startY: 0
|
||||
},
|
||||
DOM: {
|
||||
svg: null,
|
||||
line: null
|
||||
},
|
||||
|
||||
init() {
|
||||
if (this.DOM.svg) return;
|
||||
|
||||
// SVG 오버레이 생성
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.id = 'visual-linker-overlay';
|
||||
svg.style.position = 'fixed';
|
||||
svg.style.top = '0';
|
||||
svg.style.left = '0';
|
||||
svg.style.width = '100vw';
|
||||
svg.style.height = '100vh';
|
||||
svg.style.pointerEvents = 'none'; // 평소에는 클릭 방해 안 함
|
||||
svg.style.zIndex = '9999';
|
||||
svg.style.display = 'none';
|
||||
|
||||
// 💡 화살표 촉(Marker) 정의
|
||||
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||||
marker.setAttribute('id', 'arrowhead');
|
||||
marker.setAttribute('markerWidth', '10');
|
||||
marker.setAttribute('markerHeight', '7');
|
||||
marker.setAttribute('refX', '8'); // 선 끝에서 약간 안쪽
|
||||
marker.setAttribute('refY', '3.5');
|
||||
marker.setAttribute('orient', 'auto');
|
||||
|
||||
const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||||
polygon.setAttribute('points', '0 0, 10 3.5, 0 7');
|
||||
polygon.setAttribute('fill', 'var(--accent)');
|
||||
|
||||
marker.appendChild(polygon);
|
||||
defs.appendChild(marker);
|
||||
svg.appendChild(defs);
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||
line.setAttribute('stroke', 'var(--accent)');
|
||||
line.setAttribute('stroke-width', '2');
|
||||
line.setAttribute('stroke-dasharray', '5,5'); // 점선 효과
|
||||
line.setAttribute('marker-end', 'url(#arrowhead)'); // 화살표 연결
|
||||
line.style.transition = 'stroke-dashoffset 0.1s linear';
|
||||
|
||||
svg.appendChild(line);
|
||||
document.body.appendChild(svg);
|
||||
|
||||
this.DOM.svg = svg;
|
||||
this.DOM.line = line;
|
||||
|
||||
// 스크롤 시 시작점 보정
|
||||
window.addEventListener('scroll', () => {
|
||||
if (this.state.isActive) this.syncCoordinates();
|
||||
}, { passive: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* 연결 모드 시작
|
||||
*/
|
||||
start(sourceId, element) {
|
||||
if (!sourceId || !element) return;
|
||||
this.init();
|
||||
|
||||
this.state.isActive = true;
|
||||
this.state.sourceId = sourceId;
|
||||
this.state.sourceElement = element;
|
||||
|
||||
this.DOM.svg.style.display = 'block';
|
||||
document.body.classList.add('linker-active'); // 시각적 피드백용 클래스
|
||||
|
||||
this.syncCoordinates();
|
||||
|
||||
// 전역 마우스 이동 이벤트 등록
|
||||
this.onMouseMove = (e) => this.handleMouseMove(e);
|
||||
window.addEventListener('mousemove', this.onMouseMove);
|
||||
},
|
||||
|
||||
/**
|
||||
* 화면상의 좌표를 소스 요소의 현재 위치로 동기화
|
||||
*/
|
||||
syncCoordinates() {
|
||||
if (!this.state.sourceElement) return;
|
||||
const rect = this.state.sourceElement.getBoundingClientRect();
|
||||
this.state.startX = rect.left + rect.width / 2;
|
||||
this.state.startY = rect.top + rect.height / 2;
|
||||
|
||||
this.DOM.line.setAttribute('x1', this.state.startX);
|
||||
this.DOM.line.setAttribute('y1', this.state.startY);
|
||||
},
|
||||
|
||||
handleMouseMove(e) {
|
||||
if (!this.state.isActive) return;
|
||||
this.DOM.line.setAttribute('x2', e.clientX);
|
||||
this.DOM.line.setAttribute('y2', e.clientY);
|
||||
},
|
||||
|
||||
/**
|
||||
* 연결 완료 (대상 선택)
|
||||
*/
|
||||
async finish(targetId) {
|
||||
if (!this.state.isActive || !this.state.sourceId || this.state.sourceId === targetId) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceId = this.state.sourceId;
|
||||
this.cancel(); // UI 먼저 닫기
|
||||
|
||||
try {
|
||||
// 1. 소스 메모 데이터 가져오기 (본문 필요)
|
||||
const memo = await API.fetchMemo(sourceId);
|
||||
if (!memo) return;
|
||||
|
||||
// 💡 암호화된 메모리 처리 방어
|
||||
if (memo.is_encrypted) {
|
||||
alert(I18nManager.t('msg_permission_denied') || 'Encrypted memo linking is not supported in visual mode.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 본문 끝에 링크 추가
|
||||
let content = memo.content || '';
|
||||
const linkTag = `[[#${targetId}]]`;
|
||||
|
||||
// 중복 방지 체크
|
||||
const cleanContent = content.trim();
|
||||
if (cleanContent.includes(linkTag)) return;
|
||||
|
||||
const updatedContent = cleanContent + `\n\n${linkTag}`;
|
||||
|
||||
// 3. 업데이트 저장
|
||||
await API.saveMemo({
|
||||
title: memo.title,
|
||||
content: updatedContent,
|
||||
group_name: memo.group_name || '기본',
|
||||
category: memo.category,
|
||||
status: memo.status || 'active',
|
||||
color: memo.color,
|
||||
is_pinned: memo.is_pinned,
|
||||
tags: (memo.tags || []).map(t => typeof t === 'object' ? t.name : t)
|
||||
}, sourceId);
|
||||
|
||||
// 4. 데이터 갱신 (별도 팝업 없이 진행)
|
||||
if (AppService.refreshData) {
|
||||
await AppService.refreshData();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VisualLinker] Link error:', err);
|
||||
alert(`${I18nManager.t('msg_network_error') || 'Failed to link memos'}: ${err.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 연결 취소 및 초기화
|
||||
*/
|
||||
cancel() {
|
||||
if (!this.state.isActive) return;
|
||||
|
||||
this.state.isActive = false;
|
||||
this.state.sourceId = null;
|
||||
this.state.sourceElement = null;
|
||||
|
||||
if (this.DOM.svg) this.DOM.svg.style.display = 'none';
|
||||
document.body.classList.remove('linker-active');
|
||||
|
||||
window.removeEventListener('mousemove', this.onMouseMove);
|
||||
}
|
||||
};
|
||||
@@ -134,6 +134,15 @@ export const EditorManager = {
|
||||
wrapper.addEventListener('drop', async (e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
|
||||
// 💡 1. 메모 카드 드롭 처리 ([[#ID]] 삽입)
|
||||
const memoId = e.dataTransfer.getData('memo-id');
|
||||
if (memoId) {
|
||||
this.editor.focus();
|
||||
this.editor.insertText(` [[#${memoId}]] `);
|
||||
return;
|
||||
}
|
||||
|
||||
// 💡 2. 기존 파일 드롭 처리
|
||||
const files = e.dataTransfer.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
|
||||
+60
-6
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* UI 렌더링 및 이벤트를 관리하는 오케스트레이터 (Orchestrator)
|
||||
*/
|
||||
import { VisualLinker } from './components/VisualLinker.js';
|
||||
import { AppService } from './AppService.js';
|
||||
import { API } from './api.js';
|
||||
import { createMemoCardHtml } from './components/MemoCard.js';
|
||||
import { renderGroupList } from './components/SidebarUI.js';
|
||||
@@ -171,19 +173,38 @@ export const UI = {
|
||||
if (style) card.setAttribute('style', style);
|
||||
card.innerHTML = innerHtml;
|
||||
card.style.cursor = 'pointer';
|
||||
card.setAttribute('draggable', true); // 드래그 활성화
|
||||
card.title = I18nManager.t('tooltip_edit_hint');
|
||||
|
||||
// 💡 드래그 시작 시 메모 ID 저장
|
||||
card.ondragstart = (e) => {
|
||||
// 버튼이나 복사 버튼 클릭 시에는 드래그 무시 (클릭 이벤트 보전)
|
||||
if (e.target.closest('.action-btn, .copy-id-btn')) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.dataTransfer.setData('memo-id', memo.id);
|
||||
card.style.opacity = '0.5';
|
||||
};
|
||||
card.ondragend = () => {
|
||||
card.style.opacity = '1';
|
||||
};
|
||||
|
||||
card.onclick = (e) => {
|
||||
// 버튼(삭제, 핀 등) 클릭 시에는 무시
|
||||
if (e.target.closest('.action-btn')) return;
|
||||
|
||||
if (e.altKey) {
|
||||
// Alt + 클릭: 즉시 수정 모드
|
||||
handlers.onEdit(memo.id);
|
||||
} else {
|
||||
// 일반 클릭: 상세 모달 열기
|
||||
|
||||
// 단축키 없이 클릭 시 상세 모달 열기
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
this.openMemoModal(memo.id, window.allMemosCache || memos);
|
||||
}
|
||||
};
|
||||
|
||||
// 💡 마우스 오버 상태 추적 (전역 'e' 단축키용)
|
||||
card.onmouseenter = () => { window.hoveredMemoId = memo.id; };
|
||||
card.onmouseleave = () => {
|
||||
if (window.hoveredMemoId === memo.id) window.hoveredMemoId = null;
|
||||
};
|
||||
DOM.memoGrid.appendChild(card);
|
||||
|
||||
// 신규 카드에만 이벤트 바인딩
|
||||
@@ -217,6 +238,39 @@ export const UI = {
|
||||
bind('.toggle-status', handlers.onToggleStatus);
|
||||
bind('.link-item', (linkId) => this.openMemoModal(linkId, window.allMemosCache || []));
|
||||
bind('.unlock-btn', handlers.onUnlock);
|
||||
|
||||
// 💡 번호 클릭 시 링크 복사 ([[#ID]])
|
||||
const copyBtn = card.querySelector('.copy-id-btn');
|
||||
if (copyBtn) {
|
||||
copyBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 💡 Alt + 클릭 시 시각적 연결 모드 시작
|
||||
if (e.altKey) {
|
||||
VisualLinker.start(id, copyBtn);
|
||||
return;
|
||||
}
|
||||
|
||||
// 💡 연결 모드 활성화 상태에서 다른 메모의 ID를 클릭하면 연결 완료
|
||||
if (VisualLinker.state.isActive) {
|
||||
VisualLinker.finish(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const linkText = `[[#${id}]]`;
|
||||
navigator.clipboard.writeText(linkText).then(() => {
|
||||
// 간단한 피드백 표시 (임시 툴팁 변경)
|
||||
const originalTitle = copyBtn.title;
|
||||
copyBtn.title = I18nManager.t('msg_link_copied');
|
||||
copyBtn.style.color = 'var(--accent)';
|
||||
setTimeout(() => {
|
||||
copyBtn.title = originalTitle;
|
||||
copyBtn.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,12 +57,17 @@
|
||||
"title_ai": "AI Analysis",
|
||||
"title_edit": "Edit",
|
||||
"title_delete": "Delete",
|
||||
"btn_save": "Save Knowledge",
|
||||
"btn_discard": "Cancel",
|
||||
"btn_delete_memo": "Delete Knowledge",
|
||||
"btn_unlock": "Unlock",
|
||||
"label_mentioned": "Mentioned In",
|
||||
"label_mentioned": "Mentioned",
|
||||
"label_linked_memo": "Linked Memo",
|
||||
"label_no_results": "No results found.",
|
||||
"label_memo_id_prefix": "Memo #",
|
||||
"tooltip_edit_hint": "Alt + Click: Quick Edit",
|
||||
"tooltip_edit_hint": "'e' key: Quick Edit",
|
||||
"tooltip_id_copy": "Click to copy link ([[#ID]])",
|
||||
"msg_link_copied": "Link copied to clipboard!",
|
||||
"prompt_password": "Enter password to decrypt this knowledge:",
|
||||
"msg_loading": "Loading more knowledge...",
|
||||
"msg_last_memo": "This is the last piece of knowledge.",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"msg_encrypted_locked": "🚫 암호화된 메모입니다. 먼저 해독하세요.",
|
||||
"msg_auth_failed": "올바른 자격 증명이 아닙니다. 다시 시도해 주세요.",
|
||||
"msg_network_error": "네트워크 불안정 또는 서버 오류가 발생했습니다.",
|
||||
"msg_confirm_discard": "작성 중인 내용을 모두 지우고 업로드한 파일도 서버에서 삭제할까요?",
|
||||
"msg_confirm_discard": "작성 중인 내용을 지우고 창을 닫을까요?",
|
||||
"msg_alert_password_required": "암호화하려면 비밀번호를 입력해야 합니다.",
|
||||
"msg_draft_restore_confirm": "📝 임시 저장된 메모가 있습니다.\n제목: \"{title}\"\n복원하시겠습니까?",
|
||||
|
||||
@@ -57,11 +57,16 @@
|
||||
"title_ai": "AI 분석",
|
||||
"title_edit": "수정",
|
||||
"title_delete": "삭제",
|
||||
"btn_save": "지식 저장",
|
||||
"btn_discard": "작성 취소",
|
||||
"btn_delete_memo": "지식 삭제",
|
||||
"btn_unlock": "해독하기",
|
||||
"label_mentioned": "언급됨",
|
||||
"label_no_results": "조회 결과가 없습니다.",
|
||||
"label_memo_id_prefix": "메모 #",
|
||||
"tooltip_edit_hint": "Alt + 클릭: 즉시 수정",
|
||||
"tooltip_edit_hint": "'e' 키: 즉시 수정",
|
||||
"tooltip_id_copy": "클릭하여 링크 복사 ([[#ID]])",
|
||||
"msg_link_copied": "링크가 클립보드로 복사되었습니다!",
|
||||
"prompt_password": "이 지식을 해독할 비밀번호를 입력하세요:",
|
||||
"msg_loading": "더 많은 지식을 불러오는 중...",
|
||||
"msg_last_memo": "마지막 지식입니다.",
|
||||
|
||||
Reference in New Issue
Block a user