feat: release v2.0 - visual linker, instant edit, and ux improvements

This commit is contained in:
leeyj
2026-04-17 15:21:21 +09:00
parent 331411895e
commit bff0beea96
23 changed files with 560 additions and 78 deletions
+28 -4
View File
@@ -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 = '';
+6 -6
View File
@@ -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,
+183
View File
@@ -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);
}
};