joplin-plugin-explorer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Joplin Explorer
2
+
3
+ A unified sidebar plugin for [Joplin](https://joplinapp.org/) that displays notebooks and notes together in a single tree view — like a file explorer.
4
+
5
+ [中文说明](README-CN.md)
6
+
7
+ ## Screenshot
8
+
9
+ > TODO: Add screenshot
10
+
11
+ ## Features
12
+
13
+ - **Unified Tree View** — Notebooks and notes displayed in one collapsible panel
14
+ - **Custom Icons** — Shows emoji icons set in notebook settings
15
+ - **Search** — Real-time filter to quickly find notes by title
16
+ - **Sort** — Toggle between sorting by update time or title (ascending/descending)
17
+ - **Context Menus**
18
+ - Notebooks: new note, new to-do, new sub-notebook, rename, export, delete
19
+ - Notes: open, open in new window, copy Markdown link, duplicate, switch note/to-do type, toggle completed, rename, move to notebook, view properties, delete
20
+ - **Drag & Drop** — Move notes between notebooks, reorganize folder hierarchy
21
+ - **Sync Button** — Trigger synchronization with status feedback (syncing → done)
22
+ - **Auto Expand** — Automatically expands to the currently selected note on startup
23
+ - **Collapse All** — One-click collapse all notebooks
24
+ - **Scroll Position** — Preserved when navigating between notes
25
+ - **i18n** — Supports Simplified Chinese, Traditional Chinese, and English (follows Joplin's locale setting)
26
+
27
+ ## Install
28
+
29
+ ### From File
30
+
31
+ 1. Download `joplin-explorer.jpl` from the [latest release](https://github.com/lim0513/joplin-explorer/releases/latest)
32
+ 2. In Joplin, go to **Tools → Options → Plugins**
33
+ 3. Click the gear icon and select **Install from file**
34
+ 4. Choose the downloaded `.jpl` file
35
+ 5. Restart Joplin
36
+
37
+ ## Usage
38
+
39
+ After installation, the Explorer panel appears on the side of the editor. You can:
40
+
41
+ - **Click** a notebook to expand/collapse it
42
+ - **Click** a note to open it
43
+ - **Right-click** for context menu actions
44
+ - **Drag** notes or notebooks to reorganize them
45
+ - Use the **toolbar** at the top for quick actions (new notebook/note/to-do, sort, collapse all)
46
+ - Use the **search bar** to filter notes by title
47
+ - Click **Sync** at the bottom to trigger synchronization
48
+
49
+ ## Development
50
+
51
+ ```bash
52
+ # Install dependencies
53
+ npm install
54
+
55
+ # Build
56
+ npm run dist
57
+
58
+ # Watch mode
59
+ npm run dev
60
+ ```
61
+
62
+ The built plugin is output to the `publish/` directory. To test locally, set `plugins.devPluginPaths` in Joplin's settings to point to the `publish/` directory.
63
+
64
+ ## License
65
+
66
+ MIT
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "joplin-plugin-explorer",
3
+ "version": "1.0.0",
4
+ "description": "A unified sidebar that displays notebooks and notes together in a single tree view",
5
+ "author": "lim0513",
6
+ "homepage": "https://github.com/lim0513/joplin-explorer",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/lim0513/joplin-explorer.git"
10
+ },
11
+ "scripts": {
12
+ "dist": "webpack --env production",
13
+ "prepare": "npm run dist",
14
+ "dev": "webpack --watch"
15
+ },
16
+ "keywords": ["joplin-plugin", "joplin", "sidebar", "explorer", "tree-view", "notebooks"],
17
+ "license": "MIT",
18
+ "devDependencies": {
19
+ "api": "file:api",
20
+ "copy-webpack-plugin": "^11.0.0",
21
+ "ts-loader": "^9.5.1",
22
+ "typescript": "^5.3.3",
23
+ "webpack": "^5.89.0",
24
+ "webpack-cli": "^5.1.4"
25
+ },
26
+ "files": ["publish"]
27
+ }
@@ -0,0 +1 @@
1
+ (()=>{"use strict";var e={n:t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{for(var s in n)e.o(n,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:n[s]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=require("api");var n=e.n(t);async function s(e){const t=[];let s=1,o=!0;for(;o;){const i=await n().data.get(["folders",e,"notes"],{fields:["id","title","parent_id","is_todo","todo_completed","updated_time"],page:s,limit:100,order_by:"updated_time",order_dir:"DESC"});t.push(...i.items),o=i.has_more,s++}return t}function o(e,t,n=0){let s="";for(const a of e){const e=16*n;if("folder"===a.type){const l=a.note_count??0;s+=`<div class="tree-item folder" style="padding-left:${e}px" data-id="${a.id}" data-type="folder">\n <span class="toggle expanded">&#9660;</span>\n <span class="icon folder-icon">&#128193;</span>\n <span class="label">${i(a.title)}</span>\n <span class="count">(${l})</span>\n </div>`,s+=`<div class="children" data-folder-id="${a.id}">`,a.children&&(s+=o(a.children,t,n+1)),s+="</div>"}else{const n=a.id===t?" selected":"";let o="&#128196;";a.is_todo&&(o=a.todo_completed?"&#9745;":"&#9744;"),s+=`<div class="tree-item note${n}" style="padding-left:${e}px" data-id="${a.id}" data-type="note">\n <span class="icon note-icon">${o}</span>\n <span class="label">${i(a.title)}</span>\n </div>`}}return s}function i(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}n().plugins.register({onStart:async function(){await n().commands.register({name:"notesInList.toggle",label:"Toggle Notes In List panel",execute:async()=>{}}),await n().views.menuItems.create("notesInListMenuItem","notesInList.toggle",1);const e=await n().views.panels.create("notesInListPanel");await n().views.panels.addScript(e,"webview/panel.css"),await n().views.panels.setHtml(e,'<div id="notes-in-list-root"><p style="padding:12px;">Loading...</p></div>'),await n().views.panels.show(e,!0),console.info("Notes In List: panel shown");let t="";async function i(){try{console.info("Notes In List: refreshing...");const i=await async function(){const e=[];let t=1,s=!0;for(;s;){const o=await n().data.get(["folders"],{fields:["id","title","parent_id","note_count"],page:t,limit:100});e.push(...o.items),s=o.has_more,t++}return e}(),a=new Map,l=10;for(let e=0;e<i.length;e+=l){const t=i.slice(e,e+l),n=await Promise.all(t.map(e=>s(e.id)));t.forEach((e,t)=>{a.set(e.id,n[t])})}const r=function(e,t){const n=new Map;for(const t of e)n.set(t.id,{type:"folder",id:t.id,title:t.title,note_count:t.note_count,children:[]});for(const[e,s]of t){const t=n.get(e);if(t&&t.children)for(const e of s)t.children.push({type:"note",id:e.id,title:e.title||"(untitled)",is_todo:e.is_todo,todo_completed:e.todo_completed})}const s=[];for(const t of e){const e=n.get(t.id);t.parent_id&&n.has(t.parent_id)?n.get(t.parent_id).children.unshift(e):s.push(e)}return s}(i,a),c=`\n <div id="notes-in-list-root">\n <div class="toolbar">\n <button id="btn-refresh" title="Refresh">&#8635; Refresh</button>\n <input id="search-input" type="text" placeholder="Search..." />\n </div>\n <div id="tree-container">${o(r,t)}</div>\n </div>\n <script>\n function postMessage(msg) {\n webviewApi.postMessage(msg);\n }\n\n document.addEventListener('click', (e) => {\n const item = e.target.closest('.tree-item');\n if (!item) return;\n\n const type = item.dataset.type;\n const id = item.dataset.id;\n\n if (type === 'note') {\n document.querySelectorAll('.tree-item.note.selected').forEach(el => el.classList.remove('selected'));\n item.classList.add('selected');\n postMessage({ name: 'openNote', id });\n }\n\n if (type === 'folder') {\n const toggle = item.querySelector('.toggle');\n const children = item.nextElementSibling;\n if (children && children.classList.contains('children')) {\n const isExpanded = !children.classList.contains('collapsed');\n if (isExpanded) {\n children.classList.add('collapsed');\n toggle.innerHTML = '&#9654;';\n toggle.classList.remove('expanded');\n } else {\n children.classList.remove('collapsed');\n toggle.innerHTML = '&#9660;';\n toggle.classList.add('expanded');\n }\n }\n }\n });\n\n // Search filter\n const searchInput = document.getElementById('search-input');\n if (searchInput) {\n searchInput.addEventListener('input', (e) => {\n const query = e.target.value.toLowerCase();\n document.querySelectorAll('.tree-item.note').forEach(item => {\n const label = item.querySelector('.label').textContent.toLowerCase();\n item.style.display = label.includes(query) || !query ? '' : 'none';\n });\n // Show all folders when searching\n if (query) {\n document.querySelectorAll('.children').forEach(c => c.classList.remove('collapsed'));\n document.querySelectorAll('.toggle').forEach(t => { t.innerHTML = '&#9660;'; t.classList.add('expanded'); });\n }\n });\n }\n\n // Refresh button\n const btnRefresh = document.getElementById('btn-refresh');\n if (btnRefresh) {\n btnRefresh.addEventListener('click', () => {\n postMessage({ name: 'refresh' });\n });\n }\n <\/script>\n `;await n().views.panels.setHtml(e,c)}catch(e){console.error("Notes In List: refresh error",e)}}await n().views.panels.onMessage(e,async e=>{"openNote"===e.name?(t=e.id,await n().commands.execute("openNote",e.id)):"refresh"===e.name&&await i()}),await n().workspace.onNoteSelectionChange(async()=>{const e=await n().workspace.selectedNote();e&&(t=e.id),await i()}),await i()}})})();
@@ -0,0 +1,10 @@
1
+ {
2
+ "manifest_version": 1,
3
+ "id": "com.github.joplin-explorer",
4
+ "app_min_version": "2.6.0",
5
+ "version": "1.0.0",
6
+ "name": "Joplin Explorer",
7
+ "description": "A unified sidebar that displays notebooks and notes together in a single tree view",
8
+ "author": "user",
9
+ "homepage_url": "https://github.com"
10
+ }
@@ -0,0 +1,108 @@
1
+ #notes-in-list-root {
2
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
3
+ font-size: 13px;
4
+ color: var(--joplin-color);
5
+ background-color: var(--joplin-background-color);
6
+ height: 100%;
7
+ display: flex;
8
+ flex-direction: column;
9
+ user-select: none;
10
+ }
11
+
12
+ .toolbar {
13
+ display: flex;
14
+ gap: 6px;
15
+ padding: 6px 8px;
16
+ border-bottom: 1px solid var(--joplin-divider-color, #ddd);
17
+ align-items: center;
18
+ flex-shrink: 0;
19
+ }
20
+
21
+ .toolbar button {
22
+ background: var(--joplin-background-color3, #f0f0f0);
23
+ border: 1px solid var(--joplin-divider-color, #ccc);
24
+ border-radius: 4px;
25
+ padding: 3px 10px;
26
+ cursor: pointer;
27
+ color: var(--joplin-color);
28
+ font-size: 12px;
29
+ white-space: nowrap;
30
+ }
31
+
32
+ .toolbar button:hover {
33
+ background: var(--joplin-background-color-hover3, #e0e0e0);
34
+ }
35
+
36
+ #search-input {
37
+ flex: 1;
38
+ padding: 4px 8px;
39
+ border: 1px solid var(--joplin-divider-color, #ccc);
40
+ border-radius: 4px;
41
+ background: var(--joplin-background-color, #fff);
42
+ color: var(--joplin-color);
43
+ font-size: 12px;
44
+ outline: none;
45
+ }
46
+
47
+ #search-input:focus {
48
+ border-color: var(--joplin-color2, #4a9cf5);
49
+ }
50
+
51
+ #tree-container {
52
+ flex: 1;
53
+ overflow-y: auto;
54
+ padding: 4px 0;
55
+ }
56
+
57
+ .tree-item {
58
+ display: flex;
59
+ align-items: center;
60
+ padding: 4px 8px;
61
+ cursor: pointer;
62
+ gap: 4px;
63
+ border-radius: 3px;
64
+ margin: 0 4px;
65
+ }
66
+
67
+ .tree-item:hover {
68
+ background: var(--joplin-background-color-hover3, rgba(100, 100, 100, 0.1));
69
+ }
70
+
71
+ .tree-item.selected {
72
+ background: var(--joplin-selected-color, rgba(74, 156, 245, 0.2));
73
+ font-weight: 600;
74
+ }
75
+
76
+ .toggle {
77
+ width: 16px;
78
+ text-align: center;
79
+ font-size: 10px;
80
+ flex-shrink: 0;
81
+ color: var(--joplin-color-faded, #888);
82
+ }
83
+
84
+ .icon {
85
+ flex-shrink: 0;
86
+ font-size: 14px;
87
+ }
88
+
89
+ .label {
90
+ flex: 1;
91
+ overflow: hidden;
92
+ text-overflow: ellipsis;
93
+ white-space: nowrap;
94
+ }
95
+
96
+ .count {
97
+ color: var(--joplin-color-faded, #888);
98
+ font-size: 11px;
99
+ flex-shrink: 0;
100
+ }
101
+
102
+ .children.collapsed {
103
+ display: none;
104
+ }
105
+
106
+ .folder .label {
107
+ font-weight: 600;
108
+ }
@@ -0,0 +1,368 @@
1
+ /* Webview script for Notes In List panel */
2
+
3
+ // Persist scroll position across setHtml calls
4
+ var _savedScrollTop = 0;
5
+ var _isFirstRender = true;
6
+
7
+ function postMsg(msg) {
8
+ webviewApi.postMessage(msg);
9
+ }
10
+
11
+ // Save scroll position continuously
12
+ document.addEventListener('scroll', function(e) {
13
+ if (e.target && e.target.id === 'tree-container') {
14
+ _savedScrollTop = e.target.scrollTop;
15
+ }
16
+ }, true);
17
+
18
+ // Observe DOM changes to restore scroll position after setHtml
19
+ var _observer = new MutationObserver(function() {
20
+ var container = document.getElementById('tree-container');
21
+ if (!container) return;
22
+
23
+ if (_isFirstRender) {
24
+ // First render: scroll to selected note
25
+ _isFirstRender = false;
26
+ var selected = container.querySelector('.tree-item.note.selected');
27
+ if (selected) {
28
+ setTimeout(function() {
29
+ selected.scrollIntoView({ block: 'nearest', behavior: 'instant' });
30
+ }, 30);
31
+ }
32
+ } else {
33
+ // Subsequent renders: restore previous scroll position
34
+ container.scrollTop = _savedScrollTop;
35
+ }
36
+ });
37
+ _observer.observe(document.body, { childList: true, subtree: true });
38
+
39
+ function loadI18n() {
40
+ var root = document.getElementById('notes-in-list-root');
41
+ if (root && root.dataset.i18n) {
42
+ try { window._i18n = JSON.parse(root.dataset.i18n); } catch(e) {}
43
+ }
44
+ }
45
+
46
+ function T(key) {
47
+ if (!window._i18n) loadI18n();
48
+ return (window._i18n && window._i18n[key]) || key;
49
+ }
50
+
51
+ // Left click: open note / toggle folder
52
+ document.addEventListener('click', function(e) {
53
+ var existingMenu = document.getElementById('ctx-menu');
54
+ if (existingMenu) existingMenu.remove();
55
+
56
+ var item = e.target.closest('.tree-item');
57
+ if (!item) return;
58
+
59
+ var type = item.dataset.type;
60
+ var id = item.dataset.id;
61
+
62
+ if (type === 'note') {
63
+ document.querySelectorAll('.tree-item.note.selected').forEach(function(el) {
64
+ el.classList.remove('selected');
65
+ });
66
+ item.classList.add('selected');
67
+ postMsg({ name: 'openNote', id: id });
68
+ }
69
+
70
+ if (type === 'folder') {
71
+ postMsg({ name: 'toggleFolder', id: id });
72
+ }
73
+ });
74
+
75
+ // Right click: context menu
76
+ document.addEventListener('contextmenu', function(e) {
77
+ var existingMenu = document.getElementById('ctx-menu');
78
+ if (existingMenu) existingMenu.remove();
79
+
80
+ var item = e.target.closest('.tree-item');
81
+ if (!item) return;
82
+
83
+ e.preventDefault();
84
+
85
+ var type = item.dataset.type;
86
+ var id = item.dataset.id;
87
+ var title = '';
88
+ var labelEl = item.querySelector('.label');
89
+ if (labelEl) title = labelEl.textContent;
90
+
91
+ var menuHtml = '<div id="ctx-menu" class="context-menu" style="left:' + e.pageX + 'px;top:' + e.pageY + 'px;">';
92
+
93
+ if (type === 'folder') {
94
+ menuHtml += '<div class="ctx-item" data-action="newNote" data-id="' + id + '" data-type="folder">' + T('ctxNewNoteHere') + '</div>';
95
+ menuHtml += '<div class="ctx-item" data-action="newTodo" data-id="' + id + '" data-type="folder">' + T('ctxNewTodoHere') + '</div>';
96
+ menuHtml += '<div class="ctx-item" data-action="newSubNotebook" data-id="' + id + '" data-type="folder">' + T('ctxNewSubNotebook') + '</div>';
97
+ menuHtml += '<div class="ctx-sep"></div>';
98
+ menuHtml += '<div class="ctx-item" data-action="renameFolder" data-id="' + id + '" data-type="folder" data-title="' + title.replace(/"/g, '&quot;') + '">' + T('ctxRenameFolder') + '</div>';
99
+ menuHtml += '<div class="ctx-item" data-action="exportFolder" data-id="' + id + '" data-type="folder">' + T('ctxExportFolder') + '</div>';
100
+ menuHtml += '<div class="ctx-sep"></div>';
101
+ menuHtml += '<div class="ctx-item ctx-danger" data-action="deleteFolder" data-id="' + id + '" data-type="folder">' + T('ctxDeleteFolder') + '</div>';
102
+ } else if (type === 'note') {
103
+ menuHtml += '<div class="ctx-item" data-action="openNote" data-id="' + id + '" data-type="note">' + T('ctxOpenNote') + '</div>';
104
+ menuHtml += '<div class="ctx-item" data-action="openInNewWindow" data-id="' + id + '" data-type="note">' + T('ctxOpenInNewWindow') + '</div>';
105
+ menuHtml += '<div class="ctx-sep"></div>';
106
+ menuHtml += '<div class="ctx-item" data-action="copyLink" data-id="' + id + '" data-type="note">' + T('ctxCopyLink') + '</div>';
107
+ menuHtml += '<div class="ctx-item" data-action="duplicateNote" data-id="' + id + '" data-type="note">' + T('ctxDuplicateNote') + '</div>';
108
+ menuHtml += '<div class="ctx-item" data-action="switchNoteType" data-id="' + id + '" data-type="note">' + T('ctxSwitchNoteType') + '</div>';
109
+ menuHtml += '<div class="ctx-item" data-action="toggleTodo" data-id="' + id + '" data-type="note">' + T('ctxToggleTodo') + '</div>';
110
+ menuHtml += '<div class="ctx-sep"></div>';
111
+ menuHtml += '<div class="ctx-item" data-action="renameNote" data-id="' + id + '" data-type="note" data-title="' + title.replace(/"/g, '&quot;') + '">' + T('ctxRenameNote') + '</div>';
112
+ menuHtml += '<div class="ctx-item" data-action="moveNote" data-id="' + id + '" data-type="note">' + T('ctxMoveNote') + '</div>';
113
+ menuHtml += '<div class="ctx-item" data-action="noteInfo" data-id="' + id + '" data-type="note">' + T('ctxNoteInfo') + '</div>';
114
+ menuHtml += '<div class="ctx-sep"></div>';
115
+ menuHtml += '<div class="ctx-item ctx-danger" data-action="deleteNote" data-id="' + id + '" data-type="note">' + T('ctxDeleteNote') + '</div>';
116
+ }
117
+
118
+ menuHtml += '</div>';
119
+ document.body.insertAdjacentHTML('beforeend', menuHtml);
120
+
121
+ var menu = document.getElementById('ctx-menu');
122
+ var rect = menu.getBoundingClientRect();
123
+ if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px';
124
+ if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px';
125
+ });
126
+
127
+ // Context menu item click
128
+ document.addEventListener('click', function(e) {
129
+ var ctxItem = e.target.closest('.ctx-item');
130
+ if (!ctxItem) return;
131
+
132
+ var action = ctxItem.dataset.action;
133
+ var id = ctxItem.dataset.id;
134
+ var itemType = ctxItem.dataset.type;
135
+
136
+ if (action === 'renameFolder' || action === 'renameNote') {
137
+ var currentTitle = ctxItem.dataset.title || '';
138
+ var newTitle = prompt(T('promptRename'), currentTitle);
139
+ if (newTitle !== null && newTitle.trim() !== '') {
140
+ postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType, newTitle: newTitle.trim() });
141
+ }
142
+ } else if (action === 'deleteFolder') {
143
+ if (confirm(T('confirmDeleteFolder'))) {
144
+ postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType });
145
+ }
146
+ } else if (action === 'deleteNote') {
147
+ if (confirm(T('confirmDeleteNote'))) {
148
+ postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType });
149
+ }
150
+ } else if (action === 'moveNote') {
151
+ var folderName = prompt(T('promptMoveNote'));
152
+ if (folderName !== null && folderName.trim() !== '') {
153
+ postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType, targetFolderName: folderName.trim() });
154
+ }
155
+ } else {
156
+ postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType });
157
+ }
158
+
159
+ var menu = document.getElementById('ctx-menu');
160
+ if (menu) menu.remove();
161
+ });
162
+
163
+ // Close context menu
164
+ document.addEventListener('mousedown', function(e) {
165
+ if (!e.target.closest('#ctx-menu')) {
166
+ var menu = document.getElementById('ctx-menu');
167
+ if (menu) menu.remove();
168
+ }
169
+ });
170
+
171
+ // Toolbar buttons
172
+ document.addEventListener('click', function(e) {
173
+ var btn = e.target.closest('button');
174
+ if (!btn) return;
175
+
176
+ switch (btn.id) {
177
+ case 'btn-new-notebook': postMsg({ name: 'newNotebook' }); break;
178
+ case 'btn-new-note': postMsg({ name: 'newNote' }); break;
179
+ case 'btn-new-todo': postMsg({ name: 'newTodo' }); break;
180
+ case 'btn-sort': postMsg({ name: 'cycleSort' }); break;
181
+ case 'btn-collapse-all': postMsg({ name: 'collapseAll' }); break;
182
+ case 'btn-sync':
183
+ var syncBtn = document.getElementById('btn-sync');
184
+ if (syncBtn && !syncBtn.disabled) {
185
+ syncBtn.disabled = true;
186
+ syncBtn.classList.add('syncing');
187
+ syncBtn.textContent = '\uD83D\uDD04 ' + T('syncing');
188
+ postMsg({ name: 'sync' });
189
+ }
190
+ break;
191
+ }
192
+ });
193
+
194
+ // Listen for messages from plugin backend
195
+ webviewApi.onMessage(function(msg) {
196
+ if (!msg || !msg.message) return;
197
+ var m = msg.message;
198
+
199
+ if (m.name === 'syncState') {
200
+ var syncBtn = document.getElementById('btn-sync');
201
+ if (!syncBtn) return;
202
+ if (m.state === 'done') {
203
+ syncBtn.classList.remove('syncing');
204
+ syncBtn.classList.add('sync-done');
205
+ syncBtn.textContent = T('syncDone');
206
+ // Show "done" for 2 seconds, then restore
207
+ setTimeout(function() {
208
+ var btn = document.getElementById('btn-sync');
209
+ if (btn) {
210
+ btn.disabled = false;
211
+ btn.classList.remove('sync-done');
212
+ btn.textContent = '\uD83D\uDD04 ' + T('sync');
213
+ }
214
+ }, 2000);
215
+ }
216
+ } else if (m.name === 'selectNote') {
217
+ // Update selection without full re-render
218
+ document.querySelectorAll('.tree-item.note.selected').forEach(function(el) {
219
+ el.classList.remove('selected');
220
+ });
221
+ var noteEl = document.querySelector('.tree-item.note[data-id="' + m.id + '"]');
222
+ if (noteEl) {
223
+ noteEl.classList.add('selected');
224
+ noteEl.scrollIntoView({ block: 'nearest', behavior: 'instant' });
225
+ }
226
+ }
227
+ });
228
+
229
+ // ======================== Drag & Drop ========================
230
+ // Make tree items draggable
231
+ document.addEventListener('mousedown', function(e) {
232
+ var item = e.target.closest('.tree-item');
233
+ if (!item || e.button !== 0) return;
234
+ item.setAttribute('draggable', 'true');
235
+ });
236
+
237
+ document.addEventListener('dragstart', function(e) {
238
+ var item = e.target.closest('.tree-item');
239
+ if (!item) return;
240
+ e.dataTransfer.setData('text/plain', JSON.stringify({
241
+ id: item.dataset.id,
242
+ type: item.dataset.type,
243
+ }));
244
+ e.dataTransfer.effectAllowed = 'move';
245
+ item.classList.add('dragging');
246
+ });
247
+
248
+ document.addEventListener('dragend', function(e) {
249
+ var item = e.target.closest('.tree-item');
250
+ if (item) {
251
+ item.classList.remove('dragging');
252
+ item.removeAttribute('draggable');
253
+ }
254
+ // Clean up all drop indicators
255
+ document.querySelectorAll('.drop-target').forEach(function(el) { el.classList.remove('drop-target'); });
256
+ document.querySelectorAll('.drop-above').forEach(function(el) { el.classList.remove('drop-above'); });
257
+ document.querySelectorAll('.drop-below').forEach(function(el) { el.classList.remove('drop-below'); });
258
+ });
259
+
260
+ document.addEventListener('dragover', function(e) {
261
+ var target = e.target.closest('.tree-item');
262
+ if (!target) return;
263
+
264
+ e.preventDefault();
265
+ e.dataTransfer.dropEffect = 'move';
266
+
267
+ // Clean previous indicators
268
+ document.querySelectorAll('.drop-target').forEach(function(el) { el.classList.remove('drop-target'); });
269
+ document.querySelectorAll('.drop-above').forEach(function(el) { el.classList.remove('drop-above'); });
270
+ document.querySelectorAll('.drop-below').forEach(function(el) { el.classList.remove('drop-below'); });
271
+
272
+ var targetType = target.dataset.type;
273
+ var rect = target.getBoundingClientRect();
274
+ var y = e.clientY - rect.top;
275
+ var height = rect.height;
276
+
277
+ if (targetType === 'folder') {
278
+ // Top 25%: drop above, middle 50%: drop into, bottom 25%: drop below
279
+ if (y < height * 0.25) {
280
+ target.classList.add('drop-above');
281
+ } else if (y > height * 0.75) {
282
+ target.classList.add('drop-below');
283
+ } else {
284
+ target.classList.add('drop-target');
285
+ }
286
+ } else {
287
+ // Notes: just show drop-target (will move to same folder)
288
+ if (y < height * 0.5) {
289
+ target.classList.add('drop-above');
290
+ } else {
291
+ target.classList.add('drop-below');
292
+ }
293
+ }
294
+ });
295
+
296
+ document.addEventListener('dragleave', function(e) {
297
+ var target = e.target.closest('.tree-item');
298
+ if (target) {
299
+ target.classList.remove('drop-target');
300
+ target.classList.remove('drop-above');
301
+ target.classList.remove('drop-below');
302
+ }
303
+ });
304
+
305
+ document.addEventListener('drop', function(e) {
306
+ e.preventDefault();
307
+ var target = e.target.closest('.tree-item');
308
+ if (!target) return;
309
+
310
+ var data;
311
+ try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch(err) { return; }
312
+
313
+ var targetId = target.dataset.id;
314
+ var targetType = target.dataset.type;
315
+ var dragId = data.id;
316
+ var dragType = data.type;
317
+
318
+ if (dragId === targetId) return; // Can't drop on self
319
+
320
+ var rect = target.getBoundingClientRect();
321
+ var y = e.clientY - rect.top;
322
+ var height = rect.height;
323
+
324
+ if (targetType === 'folder') {
325
+ if (y >= height * 0.25 && y <= height * 0.75) {
326
+ // Drop INTO folder
327
+ postMsg({ name: 'dragDrop', dragId: dragId, dragType: dragType, targetId: targetId, position: 'into' });
328
+ } else {
329
+ // Drop above/below folder (reorder)
330
+ var pos = y < height * 0.25 ? 'above' : 'below';
331
+ postMsg({ name: 'dragDrop', dragId: dragId, dragType: dragType, targetId: targetId, position: pos });
332
+ }
333
+ } else {
334
+ // Drop on note -> move to same folder as note
335
+ postMsg({ name: 'dragDrop', dragId: dragId, dragType: dragType, targetId: targetId, position: 'into' });
336
+ }
337
+
338
+ // Clean up
339
+ document.querySelectorAll('.drop-target, .drop-above, .drop-below').forEach(function(el) {
340
+ el.classList.remove('drop-target', 'drop-above', 'drop-below');
341
+ });
342
+ });
343
+
344
+ // Search filter
345
+ document.addEventListener('input', function(e) {
346
+ if (e.target.id !== 'search-input') return;
347
+ var query = e.target.value.toLowerCase();
348
+
349
+ document.querySelectorAll('.tree-item.note').forEach(function(item) {
350
+ var label = item.querySelector('.label').textContent.toLowerCase();
351
+ item.style.display = (label.indexOf(query) >= 0 || !query) ? '' : 'none';
352
+ });
353
+
354
+ if (query) {
355
+ document.querySelectorAll('.children').forEach(function(c) { c.classList.remove('collapsed'); });
356
+ document.querySelectorAll('.toggle').forEach(function(t) { t.innerHTML = '\u25BC'; t.classList.add('expanded'); });
357
+ }
358
+
359
+ document.querySelectorAll('.children').forEach(function(childrenDiv) {
360
+ var folder = childrenDiv.previousElementSibling;
361
+ if (!folder || !folder.classList.contains('folder')) return;
362
+ if (!query) { folder.style.display = ''; childrenDiv.style.display = ''; return; }
363
+ var hasVisible = childrenDiv.querySelector('.tree-item.note:not([style*="display: none"])');
364
+ var hasVisibleSub = childrenDiv.querySelector('.tree-item.folder:not([style*="display: none"])');
365
+ if (hasVisible || hasVisibleSub) { folder.style.display = ''; childrenDiv.style.display = ''; }
366
+ else { folder.style.display = 'none'; childrenDiv.style.display = 'none'; }
367
+ });
368
+ });