From bff0beea96a8a327877a4f4d64f443722c6cfa00 Mon Sep 17 00:00:00 2001 From: leeyj Date: Fri, 17 Apr 2026 15:21:21 +0900 Subject: [PATCH] feat: release v2.0 - visual linker, instant edit, and ux improvements --- README.md | 21 ++- app/routes/memo.py | 23 +++ app/utils/__init__.py | 8 +- brain.py | 20 --- docs/Bug/20260417_link_shortcut_fix.md | 19 +++ docs/api_reference.md | 1 + docs/features.md | 12 +- docs/logic_flow.md | 10 +- docs/shortcuts.md | 5 +- docs/user_manual.md | 10 +- models.txt | Bin 0 -> 2078 bytes run.sh | 28 +++- static/app.js | 78 +++++++--- static/css/components/memo.css | 75 ++++++++++ static/js/api.js | 3 + static/js/components/ComposerManager.js | 32 ++++- static/js/components/MemoCard.js | 12 +- static/js/components/VisualLinker.js | 183 ++++++++++++++++++++++++ static/js/editor.js | 9 ++ static/js/ui.js | 66 ++++++++- static/locales/en.json | 9 +- static/locales/ko.json | 9 +- templates/components/composer.html | 5 +- 23 files changed, 560 insertions(+), 78 deletions(-) delete mode 100644 brain.py create mode 100644 docs/Bug/20260417_link_shortcut_fix.md create mode 100644 models.txt create mode 100644 static/js/components/VisualLinker.js diff --git a/README.md b/README.md index 9037663..f743aaf 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/routes/memo.py b/app/routes/memo.py index 7c76f11..e0f6c0b 100644 --- a/app/routes/memo.py +++ b/app/routes/memo.py @@ -130,6 +130,29 @@ def get_memos(): conn.close() return jsonify(memos) +@memo_bp.route('/api/memos/', 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(): diff --git a/app/utils/__init__.py b/app/utils/__init__.py index c261078..874b29a 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -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'(?` (v2.0)**: 특정 메모의 상세 정보(본문, 태그, 첨부파일 포함) 단건 조회. - `POST /api/memos//decrypt`: 암호화된 메모 복호화 요청. - `GET /api/stats/heatmap`: 히트맵 렌더링을 위한 통계 데이터 조회. diff --git a/docs/features.md b/docs/features.md index c474067..a1e92e9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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)**: + - **작성 취소**: 현재 작업 중인 내용을 버리고 안전하게 창을 닫습니다. (기존 메모는 보존됩니다.) + - **지식 삭제**: 수정 모드 내에서 별도의 빨간색 삭제 버튼을 통해 명시적으로 메모를 영구 제거합니다. diff --git a/docs/logic_flow.md b/docs/logic_flow.md index 7da4a9a..7211765 100644 --- a/docs/logic_flow.md +++ b/docs/logic_flow.md @@ -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`)를 감지하여 즉시 수정 모달을 호출합니다. --- diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 4910057..a859e2e 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -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 클릭**: 🔗 **비주얼 노드 링커** (시각적 연결 시작) +- **카드 드래그 앤 드롭**: 📥 **링크 삽입** (작성기 위로 드래그 시) - **클릭**: 상세 보기 --- diff --git a/docs/user_manual.md b/docs/user_manual.md index 9da33b1..f0fb6b7 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -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 신규) diff --git a/models.txt b/models.txt new file mode 100644 index 0000000000000000000000000000000000000000..85bc9a0761806368bb5fd0db584daa378cc97f70 GIT binary patch literal 2078 zcmb_d?QViF6g|I}>{05Zy6t(8v5iE~2)gX$x1HPCR6wNHETpALdrr^o{rdf>vBMim zY|-KgA6(U_&~O-7Ebz?#J9l&Lf8r4j+@DA6ap0+x(@QmfA7QZMm)&ufp;Z++TKpdz zDYo|C(m-CR#EB|Od{ITmuT8QMfD3#h*`AvnIR6i&K*)-y*10!dr%%in%25m%I3DZ}{LkF2t>dA;gOYGp+zJYo?+m zD87cRSUqxAW#-Tlp+Rv!Pg0PVudGEc=*|1@zJ)d@aAbyetc|sscdqWvTjW_Ut%|iv zYhSc2dSh5c#)=yHt)Xti{E(F&kxQ#$Y>`II7T>I$6;=3JR=8TeKl?4#)Uh2yr05o_ zbovFe;pnKLphu-H9XMy_j;Uh{nNpCkrjjFhJ)cC2^rt?b+#_Aka<=TyV=JYjmqZ!Y SVT2>+s$d77@|bYi0p}l6d}FEr literal 0 HcmV?d00001 diff --git a/run.sh b/run.sh index b015744..3725580 100644 --- a/run.sh +++ b/run.sh @@ -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() { diff --git a/static/app.js b/static/app.js index 1c9eb8e..12e37d0 100644 --- a/static/app.js +++ b/static/app.js @@ -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(); + } + }); + + // 💡 전역 클릭 슈퍼 디버깅 (어디가 클릭되는지 추적) }); diff --git a/static/css/components/memo.css b/static/css/components/memo.css index 6f04c41..8290a5b 100644 --- a/static/css/components/memo.css +++ b/static/css/components/memo.css @@ -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); } +} diff --git a/static/js/api.js b/static/js/api.js index 8ffed93..1c09d51 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -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()}`); }, diff --git a/static/js/components/ComposerManager.js b/static/js/components/ComposerManager.js index b89f92a..6e89f42 100644 --- a/static/js/components/ComposerManager.js +++ b/static/js/components/ComposerManager.js @@ -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 = ''; diff --git a/static/js/components/MemoCard.js b/static/js/components/MemoCard.js index 9f3ab07..aa675dc 100644 --- a/static/js/components/MemoCard.js +++ b/static/js/components/MemoCard.js @@ -81,14 +81,14 @@ export function createMemoCardHtml(memo, isDone) { const isLocked = memo.is_encrypted && (!htmlContent || htmlContent.includes('encrypted-block')); const actionsHtml = isLocked ? '' : `
- - - ${!isDone ? `` : ''} - - + + + ${!isDone ? `` : ''} + +
`; - const idBadge = `
#${memo.id}
`; + const idBadge = `
#${memo.id}
`; return { className: cardClass, diff --git a/static/js/components/VisualLinker.js b/static/js/components/VisualLinker.js new file mode 100644 index 0000000..e6a3e6f --- /dev/null +++ b/static/js/components/VisualLinker.js @@ -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); + } +}; diff --git a/static/js/editor.js b/static/js/editor.js index e948803..f07100c 100644 --- a/static/js/editor.js +++ b/static/js/editor.js @@ -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; diff --git a/static/js/ui.js b/static/js/ui.js index f319478..8c207cc 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -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); + }); + }; + } }, /** diff --git a/static/locales/en.json b/static/locales/en.json index cef7f8d..e315e3c 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -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.", diff --git a/static/locales/ko.json b/static/locales/ko.json index 3d6385a..b58dd8d 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -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": "마지막 지식입니다.", diff --git a/templates/components/composer.html b/templates/components/composer.html index 33cf3d7..8825635 100644 --- a/templates/components/composer.html +++ b/templates/components/composer.html @@ -44,8 +44,9 @@
- - + + +