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
+19 -2
View File
@@ -29,13 +29,22 @@
### ✨ 독보적인 강점
* **Intelligent Nebula**: 단순 태그로 묶는 것이 아닙니다. D3.js 기반의 그래프 시각화 통해 지식 간의 관계를 시각적으로 탐험하세요.
* **Intelligent Nebula & Visual Linker**: 단순하게 태그로 묶는 단계를 넘어, D3.js 기반의 그래프 시각화**Alt+클릭 시각적 연결** 기능을 통해 지식 간의 관계를 직관적으로 설계하세요.
* **AI Insight Hub (Optional)**: Gemini 2.0 Flash가 모든 메모를 실시간으로 요약하고 최적의 태그를 제안합니다. 당신은 기록에만 집중하세요.
* **Privacy-First Security**: 메모별로 개별 암호화를 지원합니다. 서버 관리자조차도 당신의 비밀번호 없이는 지식을 엿볼 수 없습니다.
* **High-End UX**: 글래스모피즘 기반의 모던한 UI와 하이엔드 셰이더 효과, 그리고 빠른 생산성을 위한 풍부한 단축키 시스템을 제공합니다.
---
### 🚀 최신 업데이트 (v2.0)
* **비주얼 노드 링커 (Visual Node Linker)**: `#ID` 배지를 `Alt + 클릭`하여 지식과 지식을 선으로 연결하세요. 가장 직관적인 지식 구조화 방식입니다.
* **고속 워크플로우 (Instant Edit)**: 메모 카드 위에 마우스를 올리고 `e`를 누르기만 하세요. 모달을 거치지 않고 즉시 수정 모드로 진입합니다.
* **드래그 앤 드롭 링크**: 메모를 작성기(Composer)로 드래그하여 즉시 참조 링크(`[[#ID]]`)를 삽입할 수 있습니다.
* **직관적인 행동 분리**: '작성 취소'와 '지식 삭제'를 명확히 분리하여, 실수로 지식이 유실되는 것을 방지합니다.
---
## 🆚 memos vs 뇌사료 (Comparison)
| 기능 | **memos (Open Source)** | **🧠 뇌사료 (Brain Dogfood)** |
@@ -57,7 +66,8 @@
| **새 메모** | `Ctrl + Shift + N` | 언제 어디서든 즉시 작성창 호출 |
| **슬래시 명령** | `/` | `/task`, `/ai`, `/h2` 등으로 빠른 서식 지정 |
| **지식 탐색기** | `Ctrl + Shift + E` | 저장된 지식의 구조를 한눈에 파악 |
| **즉시 수정** | `Alt + Click` | 메인 그리드에서 즉시 편집 모드 진입 |
| **즉시 수정** | `e` (Mouse Over) | 카드 위에서 바로 편집 모드 진입 |
| **비주얼 링커** | `Alt + #ID 클릭` | 지식과 지식을 선으로 잇는 시각적 연결 |
---
@@ -97,6 +107,13 @@ python brain.py
- **Advanced Security**: Grain-level encryption for individual memos your data is for your eyes only.
- **Premium Aesthetics**: Sleek glassmorphism UI with smooth micro-animations and production-ready UX.
### 🆕 What's New in v2.0
- **Visual Node Linker**: Connect memos visually by `Alt + Clicking` the #ID badge. The most intuitive way to build your knowledge web.
- **Instant Edit (e-key)**: Hover over a memo and press `e` to jump straight into editing mode. No extra clicks required.
- **Drag & Drop Linking**: Drag any memo card into the composer to instantly insert a reference link (`[[#ID]]`).
- **Refined UX**: Clearly separated 'Discard' and 'Delete' actions to prevent accidental data loss.
### Quick Start
1. Install dependencies: `pip install -r requirements.txt`
2. Create your `.env` from `.env.example` and update your master credentials.
+23
View File
@@ -130,6 +130,29 @@ def get_memos():
conn.close()
return jsonify(memos)
@memo_bp.route('/api/memos/<int:memo_id>', methods=['GET'])
@login_required
def get_memo(memo_id):
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM memos WHERE id = ?', (memo_id,))
row = c.fetchone()
if not row:
conn.close()
return jsonify({'error': 'Memo not found'}), 404
memo = dict(row)
# 태그 가져오기
c.execute('SELECT name, source FROM tags WHERE memo_id = ?', (memo_id,))
memo['tags'] = [dict(r) for r in c.fetchall()]
# 첨부파일 가져오기
c.execute('SELECT id, filename, original_name, file_type, size FROM attachments WHERE memo_id = ?', (memo_id,))
memo['attachments'] = [dict(r) for r in c.fetchall()]
conn.close()
return jsonify(memo)
@memo_bp.route('/api/stats/heatmap', methods=['GET'])
@login_required
def get_heatmap_stats():
+4 -4
View File
@@ -17,8 +17,8 @@ def parse_metadata(text, default_group=GROUP_DEFAULT):
if group_match:
group_name = group_match.group(1)
# #태그 추출 (마크다운 헤더 방지: 최소 한 개의 공백이나 시작 지점 뒤에 오는 #)
tag_matches = re.finditer(r'(?<!#)#(\w+)', text)
# #태그 추출 (마크다운 헤더 및 내부 링크[[#ID]] 방지)
tag_matches = re.finditer(r'(?<!#)(?<!\[\[)#(\w+)', text)
for match in tag_matches:
tags.append(match.group(1))
@@ -44,8 +44,8 @@ def parse_and_clean_metadata(content, ui_group=GROUP_DEFAULT, ui_tags=None):
# 3. 본문에서 기호 패턴 삭제
# $그룹 삭제
content = re.sub(r'\$\w+', '', content)
# #태그 삭제 (헤더 제외)
content = re.sub(r'(?<!#)#\w+', '', content)
# #태그 삭제 (헤더 및 내부 링크 제외)
content = re.sub(r'(?<!#)(?<!\[\[)#\w+', '', content)
content = content.strip()
# 4. 데이터 통합
-20
View File
@@ -1,20 +0,0 @@
import platform
import os
import sys
from app import create_app
app = create_app()
if __name__ == "__main__":
# OS 환경에 따른 설정 분기
is_windows = platform.system() == "Windows"
# Windows(개발/디버그): 5050 포트, Linux(운영): 5093 포트
port = 5050 if is_windows else 5093
debug_mode = True if is_windows else False
print(f"📡 {'Windows' if is_windows else 'Linux'} 환경 감지 - Port: {port}, Debug: {debug_mode}")
# 향후 Linux 서버 구축시 gunicorn / uwsgi 로 구동 권장
app.run(host="0.0.0.0", port=port, debug=debug_mode)
+19
View File
@@ -0,0 +1,19 @@
# 버그 리포트: #20260417-01
## 버그 내용
1. **Ctrl + Enter 단축키 저장 불능**: 에디터에서 `Ctrl + Enter`를 눌렀을 때 작성 창은 정상적으로 닫히는 것처럼 보이나(포커스 해제 등), 실제 저장 로직이 호출되지 않아 데이터가 손실되는 문제.
2. **[[#ID]] 내부 링크와 해시태그 충돌**: 본문 내의 `[[#123]]` 형태의 내부 링크가 해시태그(`#123`)로 오인되어 백엔드 메타데이터 정리 과정에서 삭제되거나 태그 목록에 추가되는 문제.
## 조치 사항
1. **프론트엔드 조치**:
- `static/app.js`에서 에디터 초기화 시 저장 핸들러 콜백을 명시적으로 전달.
- 에디터 초기화 및 작성기 초기화 순서를 콜백 정의 이후로 조정하여 안정성 확보.
- `EditorManager.js` 내부의 캡처 단계 키다운 이벤트 리스너가 해당 콜백을 정상적으로 호출하도록 보장.
2. **백엔드 조치**:
- `app/utils/__init__.py`의 해시태그 추출 및 삭제 정규표현식 수정.
- 부정 후방 탐색(`(?<!\[\[)`)을 추가하여 `#` 앞에 `[[`가 오는 경우 태그 처리에서 제외.
- 수정된 패턴: `(?<!#)(?<!\[\[)#(\w+)`
## 향후 주의사항
- **특수 문법 충돌 주의**: 새로운 대괄호(`[[ ]]` 등) 또는 특수 기호를 사용하는 문법을 추가할 때는 기존의 정규표현식 기반 메타데이터 추출기(`parse_metadata`)와의 충돌 여부를 반드시 사전 검증해야 함.
- **키보드 이벤트 우선순위**: Toast UI 등 서드파티 라이브러리를 사용할 경우, 라이브러리 내부에서 이벤트를 전파 중단(stopPropagation)할 수 있으므로, 단축키 처리 시 캡처 단계에서 처리하거나 라이브러리 제공 옵션을 활용해야 함.
+1
View File
@@ -52,6 +52,7 @@
### 2.1 Memos & Search
- `GET /api/memos`: 필터링된 메모 목록 및 메타데이터 통합 조회.
- **`GET /api/memos/<int:memo_id>` (v2.0)**: 특정 메모의 상세 정보(본문, 태그, 첨부파일 포함) 단건 조회.
- `POST /api/memos/<id>/decrypt`: 암호화된 메모 복호화 요청.
- `GET /api/stats/heatmap`: 히트맵 렌더링을 위한 통계 데이터 조회.
+11 -1
View File
@@ -26,8 +26,12 @@ D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유
## 🔗 4. 내부 링크 및 백링크 시스템
### 4.1 연결 문법 (`[[#ID]]`)
### 4.1 연결 문법 및 자동화 (`[[#ID]]`)
- **자동 링크**: 본문에 `[[#12]]`와 같이 입력하면 뷰어에서 클릭 가능한 링크로 변환되며, 지식 맵 상에서 두 노드 사이에 **강력한 실선**이 형성됩니다.
- **비주얼 노드 링커 (v2.0)**:
- **Alt + 클릭 연결**: 메모 카드의 #ID를 `Alt` 키와 함께 클릭하면 화살표 점선이 나타나며, 다른 메모의 #ID를 클릭하는 것만으로 두 지식을 논리적으로 연결합니다.
- **드래그 앤 드롭**: 메모 카드를 작성기(Composer)로 드래그하여 떨어뜨리면 해당 메모의 링크가 커서 위치에 즉시 삽입됩니다.
- **퀵 카피 (Quick Copy)**: #ID 배지를 클릭하는 것만으로 `[[#ID]]` 형식이 클립보드에 복사됩니다.
- **역방향 추적 (Backlinks)**: 특정 메모 카드 하단에 해당 메모를 인용 중인 다른 메모의 목록이 노출되어, 지식의 흐름을 양방향으로 추적할 수 있습니다.
## 🌡️ 5. 지식 성장 히트맵 (Intellectual Growth Heatmap)
@@ -41,3 +45,9 @@ D3.js v7 물리 시뮬레이션 엔진을 통해 파편화된 메모들을 유
## ⚙️ 7. 맞춤형 사용자 환경 (v1.5 신규)
- **고급 설정 (Advanced Categories)**: 라이트 유저를 위해 복잡한 카테고리 기능을 숨길 수 있습니다. 설정에서 활성화 시에만 작성기 칩과 사이드바 섹션이 정밀한 레이아웃으로 노출됩니다.
- **글로벌 인텔리전스 (i18n Stabilization)**: 한국어와 영어를 완벽하게 지원하며, 언어 설정을 변경할 경우 히트맵과 달력 등 동적 컴포넌트까지 실시간으로(자동 새로고침) 완벽하게 번역이 적용됩니다.
## ⚡ 8. 고속 워크플로우 (v2.0 신규)
- **즉시 수정 (Instant Edit)**: 메모 카드 위에 마우스를 올리고 `e` 키를 누르면 본문 모달을 거치지 않고 즉시 수정 모드로 진입합니다.
- **직관적 행동 분리 (Discard vs Delete)**:
- **작성 취소**: 현재 작업 중인 내용을 버리고 안전하게 창을 닫습니다. (기존 메모는 보존됩니다.)
- **지식 삭제**: 수정 모드 내에서 별도의 빨간색 삭제 버튼을 통해 명시적으로 메모를 영구 제거합니다.
+7 -3
View File
@@ -20,11 +20,15 @@ D3.js 엔진은 데이터 간의 명시적 링크 외에도 의미론적 연결
### 2.1 자동 연결 규칙 (Semantic Linking)
1. **명시적 링크**: `[[#ID]]` 패턴으로 작성된 내부 링크 (실선 표시).
2. **동일 그룹**: 같은 그룹에 속한 메모들끼리 부유하며 성단을 형성 (은은한 연결선).
3. **공통 태그**: 같은 태그를 공유하는 메모들 사이에 인력이 작용하여 근접 배치.
2. **비주얼 노드 링커 (Interactive Linker)**:
- **절차**: Alt + Badge 클릭 (Source) -> 다른 Badge 클릭 (Target) -> Target ID를 Source의 본문에 자동 추가 -> DB 저장 및 UI 갱신.
- **좌표 동기화**: `requestAnimationFrame`을 통해 마우스 커서와 SVG 연결선의 실시간 좌표를 동기화하여 지연 없는 드래잉을 구현합니다.
3. **동일 그룹**: 같은 그룹에 속한 메모들끼리 부유하며 성단을 형성 (은은한 연결선).
4. **공통 태그**: 같은 태그를 공유하는 메모들 사이에 인력이 작용하여 근접 배치.
### 2.2 이미지 경로 보정 (Path Resolution)
### 2.2 메타데이터 보호 및 이미지 보정
- **후처리 로직**: 마크다운 본문의 `img src="photo.png"`와 같은 상대 경로를 `fixImagePaths` 유틸리티가 감지하여 `/api/download/photo.png`로 자동 보정합니다.
- **즉시 수정 (e-key Logic)**: 전역 `keydown` 리스너가 마우스가 위치한 메모 ID(`window.hoveredMemoId`)를 감지하여 즉시 수정 모달을 호출합니다.
---
+4 -1
View File
@@ -12,6 +12,7 @@
- **`Ctrl + Shift + E`**: 🔍 **지식 탐색기** (사이드바 드로어 열기)
- **`Ctrl + Shift + C`**: 📅 **캘린더 토글** (사이드바 미니 달력)
- **`Ctrl + Shift + Q`** 또는 **`ESC`**: 🚪 **닫기**
- **`e` (Mouse Over)**: ⚡ **즉시 수정** (메모 카드 위에 마우스를 올리고 누름)
---
@@ -23,7 +24,9 @@
---
## 🖱️ 마우스 인터렉션
- **Alt + 클릭**: 🪄 **즉시 수정**
- **#ID 클릭**: 📋 **링크 복사** (`[[#ID]]`)
- **Alt + #ID 클릭**: 🔗 **비주얼 노드 링커** (시각적 연결 시작)
- **카드 드래그 앤 드롭**: 📥 **링크 삽입** (작성기 위로 드래그 시)
- **클릭**: 상세 보기
---
+8 -2
View File
@@ -24,8 +24,11 @@
## ✍️ 3. 메모 작성 및 스타일링
### 3.1 지식 연결 문법 (`[[#ID]]`)
메모 간의 명시적인 지식을 연결하려면 본문에 샵(#) 기호와 메모 번호를 사용하세요.
### 3.1 지식 연결 및 자동화 (`[[#ID]]`)
메모 간의 명시적인 지식을 연결하는 방법은 세 가지가 있습니다.
- **비주얼 노드 링커 (Alt + 클릭)**: 메모 카드의 #ID를 `Alt` 키와 함께 누르면 선이 나타납니다. 다른 메모의 #ID를 클릭하면 자동으로 본문에 `[[#ID]]` 링크가 삽입됩니다.
- **드래그 앤 드롭 연결**: 메모 카드를 작성기(Composer)로 직접 드래그하여 원하는 위치에 놓으면 자동으로 링크가 삽입됩니다.
- **수동 입력 및 복사**: 본문에 직접 `[[#12]]`와 같이 입력하거나, 카드의 #ID를 단순 클릭하여 복제된 링크를 붙여넣으세요.
- **효과**: 뷰어에서 클릭 시 해당 메모로 바로 이동하며, 지식 네뷸라 상에 강력한 연결선이 형성됩니다.
### 3.2 컬러 텍스트 (Color Syntax)
@@ -34,6 +37,9 @@
### 3.3 V5 메타데이터 쉴드
시스템이 생성하는 하단 메타데이터는 자동으로 관리되므로 본문 작성에만 집중하면 됩니다.
### 3.4 고속 편집 (Instant Edit)
메어 카드 위에 마우스를 올리고 `e` 키를 누르면 본문 모달을 거치지 않고 즉시 수정 모드로 진입합니다. 작업의 흐름을 끊지 않고 빠르게 메모를 다듬을 수 있습니다.
---
## ⚙️ 4. 설정 및 커스터마이징 (v1.5 신규)
BIN
View File
Binary file not shown.
+22 -6
View File
@@ -29,15 +29,31 @@ start() {
}
stop() {
# 1. PID 파일 기반 종료 시도
if [ -f $PID_FILE ]; then
PID=$(cat $PID_FILE)
echo "🛑 서버 중지하는 중... (PID: $PID)"
kill $PID
rm $PID_FILE
echo "✅ 서버가 중지되었습니다."
else
echo "⚠️ 실행 중인 서버의 PID 파일을 찾을 수 없습니다."
echo "🛑 PID 파일을 사용하여 서버 중지 시도... (PID: $PID)"
if kill -0 $PID 2>/dev/null; then
kill $PID 2>/dev/null
# 종료 대기
for i in {1..3}; do
if ! kill -0 $PID 2>/dev/null; then break; fi
sleep 1
done
fi
rm -f $PID_FILE
fi
# 2. 이름 기반 잔류 프로세스 정밀 소탕
# grep -v grep 등으로 자기 자신이나 엉뚱한 프로세스가 죽지 않도록 필터링
REMAINING_PIDS=$(ps aux | grep "python3 $APP_NAME" | grep -v "grep" | awk '{print $2}')
if [ ! -z "$REMAINING_PIDS" ]; then
echo "🔍 관리 외 잔류 프로세스 감지: $REMAINING_PIDS"
echo "🛑 잔류 프로세스를 강제 종료합니다..."
kill -9 $REMAINING_PIDS 2>/dev/null
fi
echo "✅ 모든 프로세스가 정리되었습니다."
}
status() {
+61 -17
View File
@@ -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();
}
});
// 💡 전역 클릭 슈퍼 디버깅 (어디가 클릭되는지 추적)
});
+75
View File
@@ -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); }
}
+3
View File
@@ -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()}`);
},
+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);
}
};
+9
View File
@@ -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;
+59 -5
View File
@@ -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);
});
};
}
},
/**
+7 -2
View File
@@ -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.",
+7 -2
View File
@@ -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": "마지막 지식입니다.",
+3 -2
View File
@@ -44,8 +44,9 @@
</div>
<div class="composer-actions" style="display: flex; gap: 10px; margin-top: 15px; justify-content: flex-end;">
<button type="button" id="discardBtn" class="action-btn" style="background: rgba(255, 77, 77, 0.1); color: #ff4d4d; border-color: rgba(255, 77, 77, 0.2);" data-i18n="composer_discard">Discard (Delete)</button>
<button type="submit" id="submitBtn" class="primary-btn" data-i18n="composer_save">Save Memo</button>
<button type="button" id="deleteMemoBtn" class="action-btn" style="background: rgba(255, 77, 77, 0.2); color: #ff4d4d; border-color: rgba(255, 77, 77, 0.4); display: none;" data-i18n="btn_delete_memo">지식 삭제</button>
<button type="button" id="discardBtn" class="action-btn" style="background: rgba(255, 255, 255, 0.05); color: var(--muted); border-color: rgba(255, 255, 255, 0.1);" data-i18n="btn_discard">작성 취소</button>
<button type="submit" id="submitBtn" class="primary-btn" data-i18n="btn_save">지식 저장</button>
</div>
</form>
</div>