joplin-plugin-explorer 1.1.0 → 1.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "joplin-plugin-explorer",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "A unified sidebar that displays notebooks and notes together in a single tree view",
5
5
  "author": "lim0513",
6
6
  "homepage": "https://github.com/lim0513/joplin-explorer",
package/publish/index.js CHANGED
@@ -25,6 +25,7 @@ var i18nData = {
25
25
  confirmDeleteNote: '确定删除此笔记吗?',
26
26
  promptRename: '请输入新名称:',
27
27
  promptMoveNote: '请输入目标笔记本名称:',
28
+ cancel: '取消',
28
29
  },
29
30
  'zh_TW': {
30
31
  newNotebook: '新建筆記本', newNote: '新建筆記', newTodo: '新建待辦',
@@ -45,6 +46,7 @@ var i18nData = {
45
46
  confirmDeleteFolder: '確定刪除此筆記本及其所有內容嗎?',
46
47
  confirmDeleteNote: '確定刪除此筆記嗎?',
47
48
  promptRename: '請輸入新名稱:', promptMoveNote: '請輸入目標筆記本名稱:',
49
+ cancel: '取消',
48
50
  },
49
51
  'en_US': {
50
52
  newNotebook: 'New Notebook', newNote: 'New Note', newTodo: 'New To-do',
@@ -65,6 +67,7 @@ var i18nData = {
65
67
  confirmDeleteFolder: 'Delete this notebook and all its contents?',
66
68
  confirmDeleteNote: 'Delete this note?',
67
69
  promptRename: 'Enter new name:', promptMoveNote: 'Enter target notebook name:',
70
+ cancel: 'Cancel',
68
71
  },
69
72
  'ja_JP': {
70
73
  newNotebook: '\u65B0\u898F\u30CE\u30FC\u30C8\u30D6\u30C3\u30AF', newNote: '\u65B0\u898F\u30CE\u30FC\u30C8', newTodo: '\u65B0\u898F\u30BF\u30B9\u30AF',
@@ -85,6 +88,7 @@ var i18nData = {
85
88
  confirmDeleteFolder: '\u3053\u306E\u30CE\u30FC\u30C8\u30D6\u30C3\u30AF\u3068\u305D\u306E\u5185\u5BB9\u3092\u3059\u3079\u3066\u524A\u9664\u3057\u307E\u3059\u304B\uFF1F',
86
89
  confirmDeleteNote: '\u3053\u306E\u30CE\u30FC\u30C8\u3092\u524A\u9664\u3057\u307E\u3059\u304B\uFF1F',
87
90
  promptRename: '\u65B0\u3057\u3044\u540D\u524D\u3092\u5165\u529B\uFF1A', promptMoveNote: '\u79FB\u52D5\u5148\u306E\u30CE\u30FC\u30C8\u30D6\u30C3\u30AF\u540D\uFF1A',
91
+ cancel: '\u30AD\u30E3\u30F3\u30BB\u30EB',
88
92
  },
89
93
  };
90
94
 
@@ -328,6 +332,7 @@ joplin.plugins.register({
328
332
  + ' <input id="search-input" type="text" placeholder="\uD83D\uDD0D ' + t.search + '" />'
329
333
  + ' </div>'
330
334
  + ' <div id="tree-container">' + treeHtml + '</div>'
335
+ + ' <div id="search-results" style="display:none;"></div>'
331
336
  + ' <div class="bottom-bar">'
332
337
  + ' <button id="btn-sync" title="' + t.sync + '">\uD83D\uDD04 ' + t.sync + '</button>'
333
338
  + ' </div>'
@@ -370,7 +375,7 @@ joplin.plugins.register({
370
375
  var query = msg.query;
371
376
  if (!query || !query.trim()) {
372
377
  // Empty query: tell webview to clear search results
373
- await joplin.views.panels.postMessage(panel, { name: 'searchResults', results: null, query: '' });
378
+ await joplin.views.panels.postMessage(panel, { name: 'searchResults', results: null, query: '', searchId: msg.searchId });
374
379
  return;
375
380
  }
376
381
  try {
@@ -418,7 +423,7 @@ joplin.plugins.register({
418
423
  folderName: folderNameMap[note.parent_id] || '',
419
424
  });
420
425
  }
421
- await joplin.views.panels.postMessage(panel, { name: 'searchResults', results: items, query: query });
426
+ await joplin.views.panels.postMessage(panel, { name: 'searchResults', results: items, query: query, searchId: msg.searchId });
422
427
  } catch (err) {
423
428
  console.error('Joplin Explorer: search error', err);
424
429
  }
@@ -446,10 +451,14 @@ joplin.plugins.register({
446
451
  if (itemType === 'folder') {
447
452
  switch (action) {
448
453
  case 'newNote':
449
- await joplin.data.post(['folders', id, 'notes'], null, { title: '' });
454
+ var newNote = await joplin.data.post(['notes'], null, { title: t.newNote, parent_id: id });
455
+ await joplin.commands.execute('openNote', newNote.id);
456
+ selectedNoteId = newNote.id;
450
457
  break;
451
458
  case 'newTodo':
452
- await joplin.data.post(['folders', id, 'notes'], null, { title: '', is_todo: 1 });
459
+ var newTodo = await joplin.data.post(['notes'], null, { title: t.newTodo, parent_id: id, is_todo: 1 });
460
+ await joplin.commands.execute('openNote', newTodo.id);
461
+ selectedNoteId = newTodo.id;
453
462
  break;
454
463
  case 'newSubNotebook':
455
464
  await joplin.data.post(['folders'], null, { title: t.newNotebook, parent_id: id });
@@ -461,28 +470,39 @@ joplin.plugins.register({
461
470
  if (msg.newTitle) await joplin.data.put(['folders', id], null, { title: msg.newTitle });
462
471
  break;
463
472
  case 'exportFolder':
464
- await joplin.commands.execute('exportFolders', [id]);
473
+ try { await joplin.commands.execute('exportFolders', [id]); } catch(e) {}
465
474
  break;
466
475
  }
467
476
  } else if (itemType === 'note') {
468
477
  switch (action) {
469
478
  case 'openNote':
470
479
  await joplin.commands.execute('openNote', id);
480
+ selectedNoteId = id;
471
481
  break;
472
482
  case 'openInNewWindow':
473
- await joplin.commands.execute('openNoteInNewWindow', id);
483
+ try { await joplin.commands.execute('openNoteInNewWindow', id); } catch(e) {
484
+ // Fallback: just open in main window
485
+ await joplin.commands.execute('openNote', id);
486
+ }
474
487
  break;
475
488
  case 'copyLink':
476
489
  var linkNote = await joplin.data.get(['notes', id], { fields: ['id', 'title'] });
477
490
  var mdLink = '[' + linkNote.title + '](:/' + linkNote.id + ')';
478
- await joplin.clipboard.writeText(mdLink);
491
+ try {
492
+ await joplin.clipboard.writeText(mdLink);
493
+ } catch(e) {
494
+ // Fallback: send link text to webview for copying
495
+ await joplin.views.panels.postMessage(panel, { name: 'copyText', text: mdLink });
496
+ }
479
497
  break;
480
498
  case 'duplicateNote':
481
499
  var srcNote = await joplin.data.get(['notes', id], { fields: ['title', 'body', 'parent_id', 'is_todo'] });
482
- await joplin.data.post(['notes'], null, {
500
+ var dupNote = await joplin.data.post(['notes'], null, {
483
501
  title: srcNote.title + ' (copy)', body: srcNote.body,
484
502
  parent_id: srcNote.parent_id, is_todo: srcNote.is_todo,
485
503
  });
504
+ await joplin.commands.execute('openNote', dupNote.id);
505
+ selectedNoteId = dupNote.id;
486
506
  break;
487
507
  case 'switchNoteType':
488
508
  var sn = await joplin.data.get(['notes', id], { fields: ['is_todo'] });
@@ -2,9 +2,17 @@
2
2
  "manifest_version": 1,
3
3
  "id": "com.github.joplin-explorer",
4
4
  "app_min_version": "2.6.0",
5
- "version": "1.1.0",
5
+ "version": "1.1.2",
6
6
  "name": "Joplin Explorer",
7
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"
8
+ "author": "lim0513",
9
+ "homepage_url": "https://github.com/lim0513/joplin-explorer",
10
+ "repository_url": "https://github.com/lim0513/joplin-explorer",
11
+ "keywords": ["sidebar", "explorer", "tree-view", "notebooks", "notes"],
12
+ "categories": ["appearance", "productivity"],
13
+ "screenshots": [
14
+ {"src": "screenshots/tree-view.png", "label": "Unified tree view of notebooks and notes"},
15
+ {"src": "screenshots/search.png", "label": "Full-text search with keyword highlighting"},
16
+ {"src": "screenshots/context-menu.png", "label": "Right-click context menu"}
17
+ ]
10
18
  }
Binary file
@@ -274,6 +274,25 @@ html, body {
274
274
  box-shadow: 0 2px 0 0 var(--joplin-color2, #4a9cf5);
275
275
  }
276
276
 
277
+ #search-results {
278
+ flex: 1;
279
+ overflow-y: scroll;
280
+ padding: 4px 0;
281
+ }
282
+
283
+ #search-results::-webkit-scrollbar {
284
+ width: 8px;
285
+ }
286
+
287
+ #search-results::-webkit-scrollbar-track {
288
+ background: transparent;
289
+ }
290
+
291
+ #search-results::-webkit-scrollbar-thumb {
292
+ background: var(--joplin-color-faded, #888);
293
+ border-radius: 4px;
294
+ }
295
+
277
296
  /* Search results */
278
297
  .search-status {
279
298
  padding: 8px 12px;
@@ -328,6 +347,76 @@ html, body {
328
347
  word-break: break-all;
329
348
  }
330
349
 
350
+ /* Inline input dialog */
351
+ .inline-input-overlay {
352
+ position: fixed;
353
+ top: 0; left: 0; right: 0; bottom: 0;
354
+ background: rgba(0, 0, 0, 0.3);
355
+ display: flex;
356
+ align-items: center;
357
+ justify-content: center;
358
+ z-index: 10000;
359
+ }
360
+
361
+ .inline-input-dialog {
362
+ background: var(--joplin-background-color, #fff);
363
+ border: 1px solid var(--joplin-divider-color, #ccc);
364
+ border-radius: 8px;
365
+ padding: 16px;
366
+ min-width: 240px;
367
+ max-width: 90%;
368
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
369
+ }
370
+
371
+ .inline-input-label {
372
+ font-size: 12px;
373
+ margin-bottom: 8px;
374
+ color: var(--joplin-color);
375
+ }
376
+
377
+ .inline-input-field {
378
+ width: 100%;
379
+ box-sizing: border-box;
380
+ padding: 6px 8px;
381
+ border: 1px solid var(--joplin-divider-color, #ccc);
382
+ border-radius: 4px;
383
+ background: var(--joplin-background-color, #fff);
384
+ color: var(--joplin-color);
385
+ font-size: 13px;
386
+ outline: none;
387
+ }
388
+
389
+ .inline-input-field:focus {
390
+ border-color: var(--joplin-color2, #4a9cf5);
391
+ }
392
+
393
+ .inline-input-buttons {
394
+ display: flex;
395
+ gap: 8px;
396
+ margin-top: 12px;
397
+ justify-content: flex-end;
398
+ }
399
+
400
+ .inline-input-buttons button {
401
+ padding: 5px 16px;
402
+ border: 1px solid var(--joplin-divider-color, #ccc);
403
+ border-radius: 4px;
404
+ cursor: pointer;
405
+ font-size: 12px;
406
+ color: var(--joplin-color);
407
+ background: var(--joplin-background-color3, #f0f0f0);
408
+ }
409
+
410
+ .inline-input-ok {
411
+ background: var(--joplin-color2, #4a9cf5) !important;
412
+ color: #fff !important;
413
+ border-color: var(--joplin-color2, #4a9cf5) !important;
414
+ }
415
+
416
+ .inline-input-buttons button:hover {
417
+ opacity: 0.85;
418
+ }
419
+
331
420
  mark.search-highlight {
332
421
  background: rgba(255, 213, 0, 0.4);
333
422
  color: inherit;
@@ -21,7 +21,6 @@ var _observer = new MutationObserver(function() {
21
21
  if (!container) return;
22
22
 
23
23
  if (_isFirstRender) {
24
- // First render: scroll to selected note
25
24
  _isFirstRender = false;
26
25
  var selected = container.querySelector('.tree-item.note.selected');
27
26
  if (selected) {
@@ -30,7 +29,6 @@ var _observer = new MutationObserver(function() {
30
29
  }, 30);
31
30
  }
32
31
  } else {
33
- // Subsequent renders: restore previous scroll position
34
32
  container.scrollTop = _savedScrollTop;
35
33
  }
36
34
  });
@@ -48,6 +46,49 @@ function T(key) {
48
46
  return (window._i18n && window._i18n[key]) || key;
49
47
  }
50
48
 
49
+ // Inline input dialog (replaces prompt() which may not work in webview)
50
+ function showInlineInput(label, defaultValue, callback) {
51
+ var existing = document.getElementById('inline-input-overlay');
52
+ if (existing) existing.remove();
53
+
54
+ var overlay = document.createElement('div');
55
+ overlay.id = 'inline-input-overlay';
56
+ overlay.className = 'inline-input-overlay';
57
+ overlay.innerHTML = '<div class="inline-input-dialog">'
58
+ + '<div class="inline-input-label">' + label + '</div>'
59
+ + '<input class="inline-input-field" type="text" value="' + (defaultValue || '').replace(/"/g, '&quot;') + '" />'
60
+ + '<div class="inline-input-buttons">'
61
+ + '<button class="inline-input-ok">OK</button>'
62
+ + '<button class="inline-input-cancel">' + (T('cancel') || 'Cancel') + '</button>'
63
+ + '</div></div>';
64
+
65
+ document.body.appendChild(overlay);
66
+
67
+ var input = overlay.querySelector('.inline-input-field');
68
+ input.focus();
69
+ input.select();
70
+
71
+ function submit() {
72
+ var val = input.value;
73
+ overlay.remove();
74
+ callback(val);
75
+ }
76
+ function cancel() {
77
+ overlay.remove();
78
+ callback(null);
79
+ }
80
+
81
+ overlay.querySelector('.inline-input-ok').addEventListener('click', submit);
82
+ overlay.querySelector('.inline-input-cancel').addEventListener('click', cancel);
83
+ input.addEventListener('keydown', function(e) {
84
+ if (e.key === 'Enter') submit();
85
+ if (e.key === 'Escape') cancel();
86
+ });
87
+ overlay.addEventListener('click', function(e) {
88
+ if (e.target === overlay) cancel();
89
+ });
90
+ }
91
+
51
92
  // Left click: open note / toggle folder
52
93
  document.addEventListener('click', function(e) {
53
94
  var existingMenu = document.getElementById('ctx-menu');
@@ -135,10 +176,11 @@ document.addEventListener('click', function(e) {
135
176
 
136
177
  if (action === 'renameFolder' || action === 'renameNote') {
137
178
  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
- }
179
+ showInlineInput(T('promptRename'), currentTitle, function(newTitle) {
180
+ if (newTitle !== null && newTitle.trim() !== '') {
181
+ postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType, newTitle: newTitle.trim() });
182
+ }
183
+ });
142
184
  } else if (action === 'deleteFolder') {
143
185
  if (confirm(T('confirmDeleteFolder'))) {
144
186
  postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType });
@@ -148,10 +190,11 @@ document.addEventListener('click', function(e) {
148
190
  postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType });
149
191
  }
150
192
  } 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
- }
193
+ showInlineInput(T('promptMoveNote'), '', function(folderName) {
194
+ if (folderName !== null && folderName.trim() !== '') {
195
+ postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType, targetFolderName: folderName.trim() });
196
+ }
197
+ });
155
198
  } else {
156
199
  postMsg({ name: 'contextMenu', action: action, id: id, itemType: itemType });
157
200
  }
@@ -214,12 +257,25 @@ webviewApi.onMessage(function(msg) {
214
257
  }, 2000);
215
258
  }
216
259
  } else if (m.name === 'searchResults') {
260
+ // Ignore stale search results
261
+ if (m.searchId !== undefined && m.searchId !== _searchId) return;
217
262
  if (m.results === null) {
218
- // Cleared search
219
263
  if (_searchMode) exitSearchMode();
220
264
  } else {
221
265
  renderSearchResults(m.results, m.query);
222
266
  }
267
+ } else if (m.name === 'copyText') {
268
+ // Fallback clipboard copy via webview
269
+ if (navigator.clipboard && navigator.clipboard.writeText) {
270
+ navigator.clipboard.writeText(m.text);
271
+ } else {
272
+ var ta = document.createElement('textarea');
273
+ ta.value = m.text;
274
+ document.body.appendChild(ta);
275
+ ta.select();
276
+ document.execCommand('copy');
277
+ ta.remove();
278
+ }
223
279
  } else if (m.name === 'selectNote') {
224
280
  // Update selection without full re-render
225
281
  document.querySelectorAll('.tree-item.note.selected').forEach(function(el) {
@@ -351,6 +407,7 @@ document.addEventListener('drop', function(e) {
351
407
  // ======================== Content Search ========================
352
408
  var _searchTimer = null;
353
409
  var _searchMode = false;
410
+ var _searchId = 0;
354
411
 
355
412
  function escapeRegex(str) {
356
413
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -364,10 +421,19 @@ function highlightText(text, query) {
364
421
  return escaped.replace(regex, '<mark class="search-highlight">$1</mark>');
365
422
  }
366
423
 
424
+ function showSearchContainer(show) {
425
+ var tree = document.getElementById('tree-container');
426
+ var search = document.getElementById('search-results');
427
+ if (tree) tree.style.display = show ? 'none' : '';
428
+ if (search) search.style.display = show ? '' : 'none';
429
+ }
430
+
367
431
  function renderSearchResults(results, query) {
368
- var container = document.getElementById('tree-container');
432
+ var container = document.getElementById('search-results');
369
433
  if (!container) return;
370
434
 
435
+ showSearchContainer(true);
436
+
371
437
  if (!results || results.length === 0) {
372
438
  container.innerHTML = '<div class="search-status">' + T('searchNoResult') + '</div>';
373
439
  return;
@@ -401,8 +467,7 @@ function renderSearchResults(results, query) {
401
467
 
402
468
  function exitSearchMode() {
403
469
  _searchMode = false;
404
- // Trigger a refresh to restore the tree
405
- postMsg({ name: 'refresh' });
470
+ showSearchContainer(false);
406
471
  }
407
472
 
408
473
  document.addEventListener('input', function(e) {
@@ -410,21 +475,24 @@ document.addEventListener('input', function(e) {
410
475
  var query = e.target.value.trim();
411
476
 
412
477
  if (_searchTimer) clearTimeout(_searchTimer);
478
+ _searchId++;
479
+ var currentSearchId = _searchId;
413
480
 
414
481
  if (!query) {
415
482
  if (_searchMode) exitSearchMode();
416
483
  return;
417
484
  }
418
485
 
419
- // Show "searching..." immediately
420
- var container = document.getElementById('tree-container');
421
- if (container) {
422
- container.innerHTML = '<div class="search-status">' + T('searching') + '</div>';
486
+ _searchMode = true;
487
+ var searchContainer = document.getElementById('search-results');
488
+ if (searchContainer) {
489
+ searchContainer.innerHTML = '<div class="search-status">' + T('searching') + '</div>';
423
490
  }
491
+ showSearchContainer(true);
424
492
 
425
493
  // Debounce: wait 400ms after typing stops
426
494
  _searchTimer = setTimeout(function() {
427
- postMsg({ name: 'search', query: query });
495
+ postMsg({ name: 'search', query: query, searchId: currentSearchId });
428
496
  }, 400);
429
497
  });
430
498