mirror of
https://github.com/sotam0316/brain_dogfood.git
synced 2026-04-24 19:48:35 +09:00
184 lines
6.1 KiB
JavaScript
184 lines
6.1 KiB
JavaScript
/**
|
|
* 메모 간 시각적 연결(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);
|
|
}
|
|
};
|