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 +66 -0
- package/package.json +27 -0
- package/publish/index.js +1 -0
- package/publish/manifest.json +10 -0
- package/publish/webview/panel.css +108 -0
- package/publish/webview/panel.js +368 -0
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
|
+
}
|
package/publish/index.js
ADDED
|
@@ -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">▼</span>\n <span class="icon folder-icon">📁</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="📄";a.is_todo&&(o=a.todo_completed?"☑":"☐"),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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}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">↻ 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 = '▶';\n toggle.classList.remove('expanded');\n } else {\n children.classList.remove('collapsed');\n toggle.innerHTML = '▼';\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 = '▼'; 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, '"') + '">' + 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, '"') + '">' + 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
|
+
});
|