commit b0842920eba5eb016666df03e694619645fc1564 Author: BCS <120721474+BCS1037@users.noreply.github.com> Date: Thu Apr 2 09:21:08 2026 +0800 feat: initial release plugin side-bookmark v0.0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9aa853a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +PRD/ +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cbdee3e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.0.1] - 2026-04-01 + +### Added +- Initial release of the **Side Bookmark** plugin. +- In-app sidebar web browser functionality. +- Bookmark saving and nested folder organization. +- Interactive drag-and-drop feature to rearrange bookmarks and folders. +- Complete data persistence in the Obsidian vault settings. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c296a56 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Side Bookmark + +[中文说明 (Chinese Version)](#中文版-side-bookmark) + +Side Bookmark is an Obsidian plugin that embeds a fully functional web browser right in your sidebar. It allows you to browse websites, collect references, and manage your bookmarks without ever leaving your vault. + +## Features + +- 🌐 **In-App Browser**: Open and navigate any web page directly within the Obsidian sidebar. +- 🔖 **Bookmark Management**: Save frequently visited sites as bookmarks. Organize them effectively using folders. +- 🖱️ **Drag-and-Drop Organization**: Easily reorder and organize your bookmarks and folders via an intuitive drag-and-drop interface. +- 💾 **Persistent Settings**: Your bookmarks, folders, and browser preferences are securely saved within your vault. + +## Installation + +### Manual Installation +1. Download `main.js`, `styles.css`, and `manifest.json` from the [Releases](https://github.com/bcs1037/side-bookmark/releases) page. +2. Create a folder named `side-bookmark` inside `.obsidian/plugins/` in your vault. +3. Place the downloaded files into the `side-bookmark` folder. +4. Reload Obsidian and enable **Side Bookmark** in the Community Plugins settings. + +--- + +# 中文版 Side Bookmark + +Side Bookmark 是一款强大的 Obsidian 插件,可以在您的侧边栏中嵌入一个功能完整的网页浏览器。它能让您在不离开笔记库的情况下,直接浏览网页、收集参考资料并管理书签。 + +## 主要功能 + +- 🌐 **应用内浏览器**:直接在 Obsidian 侧边栏中打开流转和浏览任意网页。 +- 🔖 **书签管理**:将频繁访问的网站保存为书签,并使用文件夹对它们进行高效整理。 +- 🖱️ **拖拽排序管理**:通过直观的拖放界面,轻松对书签和文件夹进行重新排序和组织。 +- 💾 **设置持久化**:您的书签、文件夹和浏览器偏好设置都安全的保存在您的笔记库中。 + +## 安装方法 + +### 手动安装 +1. 前往 GitHub 的 [Releases](https://github.com/bcs1037/side-bookmark/releases) 页面,下载最新的 `main.js`、`styles.css` 和 `manifest.json` 文件。 +2. 在您的 Obsidian 笔记库的 `.obsidian/plugins/` 目录下创建一个名为 `side-bookmark` 的文件夹。 +3. 将下载的文件放入该文件夹中。 +4. 重新加载 Obsidian,并在“第三方插件”设置中启用 **Side Bookmark**。 diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..1c74a14 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,49 @@ +import esbuild from "esbuild"; +import process from "process"; +import { builtinModules } from 'node:module'; + +const banner = +`/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === "production"); + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtinModules], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..48ab1bf --- /dev/null +++ b/main.js @@ -0,0 +1,7 @@ +/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ + +var C=Object.defineProperty;var S=Object.getOwnPropertyDescriptor;var M=Object.getOwnPropertyNames;var _=Object.prototype.hasOwnProperty;var A=(n,e)=>{for(var t in e)C(n,t,{get:e[t],enumerable:!0})},N=(n,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of M(e))!_.call(n,s)&&s!==t&&C(n,s,{get:()=>e[s],enumerable:!(r=S(e,s))||r.enumerable});return n};var O=n=>N(C({},"__esModule",{value:!0}),n);var U={};A(U,{default:()=>T});module.exports=O(U);var F=require("obsidian");var P={bookmarks:[],folders:[],defaultUrl:"https://www.google.com",showBookmarkPanel:!0},m="side-bookmark-view";function L(){return Date.now().toString(36)+Math.random().toString(36).substring(2,9)}var f=class{constructor(e){this.listeners=[];this.plugin=e,this.data={...P}}async load(){let e=await this.plugin.loadData();e&&(this.data={...P,...e})}async save(){await this.plugin.saveData(this.data)}onChange(e){return this.listeners.push(e),()=>{this.listeners=this.listeners.filter(t=>t!==e)}}notify(){for(let e of this.listeners)e()}get defaultUrl(){return this.data.defaultUrl}set defaultUrl(e){this.data.defaultUrl=e}get showBookmarkPanel(){return this.data.showBookmarkPanel}set showBookmarkPanel(e){this.data.showBookmarkPanel=e}get bookmarks(){return this.data.bookmarks}get folders(){return this.data.folders}async addBookmark(e,t,r=null){let s=this.data.bookmarks.filter(l=>l.folderId===r),o=s.length>0?Math.max(...s.map(l=>l.order)):-1,i={id:L(),type:"bookmark",title:e,url:t,folderId:r,order:o+1};return this.data.bookmarks.push(i),await this.save(),this.notify(),i}async removeBookmark(e){this.data.bookmarks=this.data.bookmarks.filter(t=>t.id!==e),await this.save(),this.notify()}async updateBookmark(e,t){let r=this.data.bookmarks.find(s=>s.id===e);r&&(Object.assign(r,t),await this.save(),this.notify())}async moveBookmark(e,t){await this.updateBookmark(e,{folderId:t})}async addFolder(e,t=null){let r=this.data.folders.filter(i=>i.parentId===t),s=r.length>0?Math.max(...r.map(i=>i.order)):-1,o={id:L(),type:"folder",name:e,parentId:t,collapsed:!1,order:s+1};return this.data.folders.push(o),await this.save(),this.notify(),o}async removeFolder(e){let t=this.getDescendantFolderIds(e),r=[e,...t];this.data.bookmarks=this.data.bookmarks.filter(s=>{var o;return!r.includes((o=s.folderId)!=null?o:"")}),this.data.folders=this.data.folders.filter(s=>!r.includes(s.id)),await this.save(),this.notify()}async updateFolder(e,t){let r=this.data.folders.find(s=>s.id===e);r&&(Object.assign(r,t),await this.save(),this.notify())}async toggleFolder(e){let t=this.data.folders.find(r=>r.id===e);t&&(t.collapsed=!t.collapsed,await this.save(),this.notify())}getBookmarksByFolder(e){return this.data.bookmarks.filter(t=>t.folderId===e).sort((t,r)=>t.order-r.order)}getSubFolders(e){return this.data.folders.filter(t=>t.parentId===e).sort((t,r)=>t.order-r.order)}getFolder(e){return this.data.folders.find(t=>t.id===e)}getBookmark(e){return this.data.bookmarks.find(t=>t.id===e)}getTreeItems(e){let t=this.getSubFolders(e),r=this.getBookmarksByFolder(e);return[...t,...r].sort((o,i)=>o.order-i.order)}hasBookmarkWithUrl(e){return this.data.bookmarks.some(t=>t.url===e)}getDescendantFolderIds(e){let t=this.data.folders.filter(s=>s.parentId===e),r=[];for(let s of t)r.push(s.id),r.push(...this.getDescendantFolderIds(s.id));return r}};var D=require("obsidian");var k=require("obsidian"),b=class{constructor(e){this.webviewEl=null;this.currentUrl="";this.currentTitle="";this.onAddBookmark=null;this.containerEl=e.createDiv({cls:"sb-browser-panel"}),this.navBarEl=this.containerEl.createDiv({cls:"sb-nav-bar"});let t=this.navBarEl.createDiv({cls:"sb-nav-buttons"});this.backBtn=t.createDiv({cls:"sb-nav-btn",attr:{"aria-label":"\u540E\u9000"}}),(0,k.setIcon)(this.backBtn,"arrow-left"),this.backBtn.addEventListener("click",()=>this.goBack()),this.forwardBtn=t.createDiv({cls:"sb-nav-btn",attr:{"aria-label":"\u524D\u8FDB"}}),(0,k.setIcon)(this.forwardBtn,"arrow-right"),this.forwardBtn.addEventListener("click",()=>this.goForward()),this.refreshBtn=t.createDiv({cls:"sb-nav-btn",attr:{"aria-label":"\u5237\u65B0"}}),(0,k.setIcon)(this.refreshBtn,"refresh-cw"),this.refreshBtn.addEventListener("click",()=>this.reload()),this.urlInput=this.navBarEl.createEl("input",{cls:"sb-url-input",attr:{type:"text",placeholder:"\u8F93\u5165\u7F51\u5740...",spellcheck:"false"}}),this.urlInput.addEventListener("keydown",r=>{r.key==="Enter"&&this.navigateToInput()}),this.urlInput.addEventListener("focus",()=>{this.urlInput.select()}),this.bookmarkBtn=this.navBarEl.createDiv({cls:"sb-nav-btn sb-bookmark-btn",attr:{"aria-label":"\u6DFB\u52A0\u4E66\u7B7E"}}),(0,k.setIcon)(this.bookmarkBtn,"star"),this.bookmarkBtn.addEventListener("click",()=>{this.onAddBookmark&&this.currentUrl&&this.onAddBookmark(this.currentTitle||this.currentUrl,this.currentUrl)}),this.webviewContainer=this.containerEl.createDiv({cls:"sb-webview-container"})}navigate(e){let t=e.trim();t&&(/^https?:\/\//i.test(t)||(t="https://"+t),this.currentUrl=t,this.urlInput.value=t,this.createOrUpdateWebview(t))}goBack(){var e,t;this.webviewEl&&((t=(e=this.webviewEl).goBack)==null||t.call(e))}goForward(){var e,t;this.webviewEl&&((t=(e=this.webviewEl).goForward)==null||t.call(e))}reload(){var e,t;this.webviewEl&&((t=(e=this.webviewEl).reload)==null||t.call(e))}getUrl(){return this.currentUrl}getTitle(){return this.currentTitle}destroy(){this.webviewEl&&(this.webviewEl.remove(),this.webviewEl=null),this.containerEl.remove()}navigateToInput(){let e=this.urlInput.value.trim();e&&this.navigate(e)}createOrUpdateWebview(e){this.webviewEl&&this.webviewEl.remove();let t=document.createElement("webview");t.setAttribute("src",e),t.setAttribute("allowpopups",""),t.addClass("sb-webview"),t.addEventListener("did-navigate",r=>{let s=r;s.url&&(this.currentUrl=s.url,this.urlInput.value=s.url)}),t.addEventListener("did-navigate-in-page",r=>{let s=r;s.url&&(this.currentUrl=s.url,this.urlInput.value=s.url)}),t.addEventListener("page-title-updated",r=>{let s=r;s.title&&(this.currentTitle=s.title)}),t.addEventListener("dom-ready",()=>{let r=t;r.getURL&&(this.currentUrl=r.getURL(),this.urlInput.value=this.currentUrl),r.getTitle&&(this.currentTitle=r.getTitle())}),t.addEventListener("new-window",r=>{r.url&&this.navigate(r.url)}),this.webviewContainer.empty(),this.webviewContainer.appendChild(t),this.webviewEl=t}};var d=require("obsidian");var a=require("obsidian"),p=class extends a.Modal{constructor(e,t,r="",s="",o=null,i){super(e),this.store=t,this.title=r,this.url=s,this.folderId=o,this.onSave=i!=null?i:null}onOpen(){let{contentEl:e}=this;e.empty(),e.addClass("side-bookmark-modal"),e.createEl("h3",{text:"\u6DFB\u52A0\u4E66\u7B7E"}),new a.Setting(e).setName("\u6807\u9898").addText(t=>t.setPlaceholder("\u8F93\u5165\u4E66\u7B7E\u6807\u9898").setValue(this.title).onChange(r=>{this.title=r})),new a.Setting(e).setName("\u7F51\u5740").addText(t=>t.setPlaceholder("https://example.com").setValue(this.url).onChange(r=>{this.url=r})),new a.Setting(e).setName("\u6587\u4EF6\u5939").addDropdown(t=>{var r;t.addOption("__root__","\uFF08\u65E0\u6587\u4EF6\u5939\uFF09");for(let s of this.store.folders)t.addOption(s.id,this.getFolderPath(s));t.setValue((r=this.folderId)!=null?r:"__root__"),t.onChange(s=>{this.folderId=s==="__root__"?null:s})}),new a.Setting(e).addButton(t=>t.setButtonText("\u4FDD\u5B58").setCta().onClick(async()=>{if(!this.url.trim())return;this.title.trim()||(this.title=this.url);let r=this.url.trim();/^https?:\/\//i.test(r)||(r="https://"+r),this.onSave?this.onSave(this.title.trim(),r,this.folderId):await this.store.addBookmark(this.title.trim(),r,this.folderId),this.close()})).addButton(t=>t.setButtonText("\u53D6\u6D88").onClick(()=>this.close()))}onClose(){let{contentEl:e}=this;e.empty()}getFolderPath(e){let t=[e.name],r=e;for(;r!=null&&r.parentId;)r=this.store.getFolder(r.parentId),r&&t.unshift(r.name);return t.join(" / ")}},w=class extends a.Modal{constructor(e,t,r,s,o,i){super(e),this.store=t,this.bookmarkId=r,this.title=s,this.url=o,this.folderId=i}onOpen(){let{contentEl:e}=this;e.empty(),e.addClass("side-bookmark-modal"),e.createEl("h3",{text:"\u7F16\u8F91\u4E66\u7B7E"}),new a.Setting(e).setName("\u6807\u9898").addText(t=>t.setPlaceholder("\u8F93\u5165\u4E66\u7B7E\u6807\u9898").setValue(this.title).onChange(r=>{this.title=r})),new a.Setting(e).setName("\u7F51\u5740").addText(t=>t.setPlaceholder("https://example.com").setValue(this.url).onChange(r=>{this.url=r})),new a.Setting(e).setName("\u6587\u4EF6\u5939").addDropdown(t=>{var r;t.addOption("__root__","\uFF08\u65E0\u6587\u4EF6\u5939\uFF09");for(let s of this.store.folders)t.addOption(s.id,this.getFolderPath(s));t.setValue((r=this.folderId)!=null?r:"__root__"),t.onChange(s=>{this.folderId=s==="__root__"?null:s})}),new a.Setting(e).addButton(t=>t.setButtonText("\u4FDD\u5B58").setCta().onClick(async()=>{if(!this.url.trim())return;this.title.trim()||(this.title=this.url);let r=this.url.trim();/^https?:\/\//i.test(r)||(r="https://"+r),await this.store.updateBookmark(this.bookmarkId,{title:this.title.trim(),url:r,folderId:this.folderId}),this.close()})).addButton(t=>t.setButtonText("\u53D6\u6D88").onClick(()=>this.close()))}onClose(){let{contentEl:e}=this;e.empty()}getFolderPath(e){let t=[e.name],r=e;for(;r!=null&&r.parentId;)r=this.store.getFolder(r.parentId),r&&t.unshift(r.name);return t.join(" / ")}},u=class extends a.Modal{constructor(e,t,r=null){super(e),this.store=t,this.folderName="",this.parentId=r}onOpen(){let{contentEl:e}=this;e.empty(),e.addClass("side-bookmark-modal"),e.createEl("h3",{text:"\u65B0\u5EFA\u6587\u4EF6\u5939"}),new a.Setting(e).setName("\u6587\u4EF6\u5939\u540D\u79F0").addText(t=>t.setPlaceholder("\u8F93\u5165\u6587\u4EF6\u5939\u540D\u79F0").setValue(this.folderName).onChange(r=>{this.folderName=r})),new a.Setting(e).setName("\u7236\u6587\u4EF6\u5939").addDropdown(t=>{var r;t.addOption("__root__","\uFF08\u6839\u7EA7\u522B\uFF09");for(let s of this.store.folders)t.addOption(s.id,this.getFolderPath(s));t.setValue((r=this.parentId)!=null?r:"__root__"),t.onChange(s=>{this.parentId=s==="__root__"?null:s})}),new a.Setting(e).addButton(t=>t.setButtonText("\u521B\u5EFA").setCta().onClick(async()=>{this.folderName.trim()&&(await this.store.addFolder(this.folderName.trim(),this.parentId),this.close())})).addButton(t=>t.setButtonText("\u53D6\u6D88").onClick(()=>this.close()))}onClose(){let{contentEl:e}=this;e.empty()}getFolderPath(e){let t=[e.name],r=e;for(;r!=null&&r.parentId;)r=this.store.getFolder(r.parentId),r&&t.unshift(r.name);return t.join(" / ")}},B=class extends a.Modal{constructor(e,t,r,s){super(e),this.store=t,this.folderId=r,this.folderName=s}onOpen(){let{contentEl:e}=this;e.empty(),e.addClass("side-bookmark-modal"),e.createEl("h3",{text:"\u7F16\u8F91\u6587\u4EF6\u5939"}),new a.Setting(e).setName("\u6587\u4EF6\u5939\u540D\u79F0").addText(t=>t.setPlaceholder("\u8F93\u5165\u6587\u4EF6\u5939\u540D\u79F0").setValue(this.folderName).onChange(r=>{this.folderName=r})),new a.Setting(e).addButton(t=>t.setButtonText("\u4FDD\u5B58").setCta().onClick(async()=>{this.folderName.trim()&&(await this.store.updateFolder(this.folderId,{name:this.folderName.trim()}),this.close())})).addButton(t=>t.setButtonText("\u53D6\u6D88").onClick(()=>this.close()))}onClose(){let{contentEl:e}=this;e.empty()}},v=class extends a.Modal{constructor(e,t,r){super(e),this.message=t,this.onConfirm=r}onOpen(){let{contentEl:e}=this;e.empty(),e.addClass("side-bookmark-modal"),e.createEl("h3",{text:"\u786E\u8BA4\u5220\u9664"}),e.createEl("p",{text:this.message}),new a.Setting(e).addButton(t=>t.setButtonText("\u5220\u9664").setWarning().onClick(async()=>{await this.onConfirm(),this.close()})).addButton(t=>t.setButtonText("\u53D6\u6D88").onClick(()=>this.close()))}onClose(){let{contentEl:e}=this;e.empty()}};var E=class{constructor(e,t,r){this.collapsed=!1;this.unsubscribe=null;this.onNavigate=null;this.app=e,this.store=r,this.containerEl=t.createDiv({cls:"sb-bookmark-panel"}),this.headerEl=this.containerEl.createDiv({cls:"sb-bookmark-header"});let s=this.headerEl.createDiv({cls:"sb-bookmark-header-left"}),o=s.createDiv({cls:"sb-collapse-icon"});(0,d.setIcon)(o,"chevron-down"),s.createSpan({text:"\u4E66\u7B7E",cls:"sb-bookmark-title"}),s.addEventListener("click",()=>{this.toggleCollapse()});let l=this.headerEl.createDiv({cls:"sb-bookmark-header-right"}).createDiv({cls:"sb-add-btn-group"}),c=l.createDiv({cls:"sb-header-btn",attr:{"aria-label":"\u6DFB\u52A0\u4E66\u7B7E"}});(0,d.setIcon)(c,"plus"),c.addEventListener("click",x=>{x.stopPropagation(),new p(this.app,this.store).open()});let h=l.createDiv({cls:"sb-header-btn",attr:{"aria-label":"\u65B0\u5EFA\u6587\u4EF6\u5939"}});(0,d.setIcon)(h,"folder-plus"),h.addEventListener("click",x=>{x.stopPropagation(),new u(this.app,this.store).open()}),this.listEl=this.containerEl.createDiv({cls:"sb-bookmark-list"}),this.unsubscribe=this.store.onChange(()=>{this.renderList()}),this.renderList()}toggleCollapse(){this.collapsed=!this.collapsed,this.containerEl.toggleClass("sb-collapsed",this.collapsed);let e=this.headerEl.querySelector(".sb-collapse-icon");e&&(e.empty(),(0,d.setIcon)(e,this.collapsed?"chevron-right":"chevron-down"))}destroy(){this.unsubscribe&&this.unsubscribe(),this.containerEl.remove()}renderList(){if(this.listEl.empty(),this.store.getTreeItems(null).length===0){let t=this.listEl.createDiv({cls:"sb-empty-state"}),r=t.createDiv({cls:"sb-empty-icon"});(0,d.setIcon)(r,"bookmark"),t.createDiv({text:"\u6682\u65E0\u4E66\u7B7E",cls:"sb-empty-text"}),t.createDiv({text:"\u70B9\u51FB + \u6309\u94AE\u6216 \u2B50 \u6309\u94AE\u6DFB\u52A0\u4E66\u7B7E",cls:"sb-empty-hint"});return}this.renderTreeLevel(this.listEl,null,0)}renderTreeLevel(e,t,r){let s=this.store.getTreeItems(t);for(let o of s)o.type==="folder"?this.renderFolder(e,o,r):this.renderBookmark(e,o,r)}renderFolder(e,t,r){let s=e.createDiv({cls:"sb-tree-item sb-folder-item",attr:{"data-id":t.id,"data-type":"folder",draggable:"true"}});s.style.paddingLeft=`${r*16+4}px`;let o=s.createDiv({cls:"sb-tree-item-row"}),i=o.createDiv({cls:"sb-folder-collapse"});(0,d.setIcon)(i,t.collapsed?"chevron-right":"chevron-down");let l=o.createDiv({cls:"sb-item-icon"});(0,d.setIcon)(l,t.collapsed?"folder":"folder-open"),o.createSpan({text:t.name,cls:"sb-item-label"});let c=this.store.getBookmarksByFolder(t.id).length+this.store.getSubFolders(t.id).length;if(c>0&&o.createSpan({text:`${c}`,cls:"sb-count-badge"}),o.addEventListener("click",()=>{this.store.toggleFolder(t.id)}),o.addEventListener("contextmenu",h=>{h.preventDefault(),h.stopPropagation(),this.showFolderContextMenu(h,t)}),this.setupDragEvents(s,t),!t.collapsed){let h=e.createDiv({cls:"sb-folder-children"});this.renderTreeLevel(h,t.id,r+1)}}renderBookmark(e,t,r){let s=e.createDiv({cls:"sb-tree-item sb-bookmark-item",attr:{"data-id":t.id,"data-type":"bookmark",draggable:"true"}});s.style.paddingLeft=`${r*16+24}px`;let o=s.createDiv({cls:"sb-tree-item-row"}),i=o.createDiv({cls:"sb-item-icon"});(0,d.setIcon)(i,"globe"),o.createSpan({text:t.title,cls:"sb-item-label"}),o.addEventListener("click",()=>{this.onNavigate&&this.onNavigate(t.url)}),o.addEventListener("contextmenu",l=>{l.preventDefault(),l.stopPropagation(),this.showBookmarkContextMenu(l,t)}),this.setupDragEvents(s,t)}showBookmarkContextMenu(e,t){let r=new d.Menu;r.addItem(s=>{s.setTitle("\u6253\u5F00"),s.setIcon("external-link"),s.onClick(()=>{this.onNavigate&&this.onNavigate(t.url)})}),r.addSeparator(),r.addItem(s=>{s.setTitle("\u7F16\u8F91"),s.setIcon("pencil"),s.onClick(()=>{new w(this.app,this.store,t.id,t.title,t.url,t.folderId).open()})}),r.addItem(s=>{s.setTitle("\u79FB\u52A8\u5230..."),s.setIcon("folder-input"),s.onClick(()=>{this.showMoveMenu(e,t)})}),r.addSeparator(),r.addItem(s=>{s.setTitle("\u5220\u9664"),s.setIcon("trash-2"),s.onClick(()=>{new v(this.app,`\u786E\u5B9A\u8981\u5220\u9664\u4E66\u7B7E"${t.title}"\u5417\uFF1F`,async()=>{await this.store.removeBookmark(t.id)}).open()})}),r.showAtMouseEvent(e)}showFolderContextMenu(e,t){let r=new d.Menu;r.addItem(s=>{s.setTitle("\u6DFB\u52A0\u4E66\u7B7E\u5230\u6B64\u6587\u4EF6\u5939"),s.setIcon("plus"),s.onClick(()=>{new p(this.app,this.store,"","",t.id).open()})}),r.addItem(s=>{s.setTitle("\u6DFB\u52A0\u5B50\u6587\u4EF6\u5939"),s.setIcon("folder-plus"),s.onClick(()=>{new u(this.app,this.store,t.id).open()})}),r.addSeparator(),r.addItem(s=>{s.setTitle("\u91CD\u547D\u540D"),s.setIcon("pencil"),s.onClick(()=>{new B(this.app,this.store,t.id,t.name).open()})}),r.addSeparator(),r.addItem(s=>{s.setTitle("\u5220\u9664\u6587\u4EF6\u5939"),s.setIcon("trash-2"),s.onClick(()=>{let o=this.store.getBookmarksByFolder(t.id).length,i=this.store.getSubFolders(t.id).length,l=`\u786E\u5B9A\u8981\u5220\u9664\u6587\u4EF6\u5939"${t.name}"\u5417\uFF1F`;(o>0||i>0)&&(l+=` +\u5176\u4E2D\u5305\u542B ${o} \u4E2A\u4E66\u7B7E\u548C ${i} \u4E2A\u5B50\u6587\u4EF6\u5939\uFF0C\u5C06\u4E00\u5E76\u5220\u9664\u3002`),new v(this.app,l,async()=>{await this.store.removeFolder(t.id)}).open()})}),r.showAtMouseEvent(e)}showMoveMenu(e,t){let r=new d.Menu;t.folderId!==null&&(r.addItem(s=>{s.setTitle("\uFF08\u6839\u7EA7\u522B\uFF09"),s.setIcon("corner-left-up"),s.onClick(async()=>{await this.store.moveBookmark(t.id,null)})}),r.addSeparator());for(let s of this.store.folders)s.id!==t.folderId&&r.addItem(o=>{o.setTitle(s.name),o.setIcon("folder"),o.onClick(async()=>{await this.store.moveBookmark(t.id,s.id)})});r.showAtMouseEvent(e)}setupDragEvents(e,t){e.addEventListener("dragstart",r=>{r.dataTransfer&&(r.dataTransfer.setData("text/plain",JSON.stringify({id:t.id,type:t.type})),r.dataTransfer.effectAllowed="move"),e.addClass("sb-dragging")}),e.addEventListener("dragend",()=>{e.removeClass("sb-dragging"),this.listEl.querySelectorAll(".sb-drop-target").forEach(r=>r.removeClass("sb-drop-target"))}),e.addEventListener("dragover",r=>{r.preventDefault(),r.dataTransfer&&(r.dataTransfer.dropEffect="move"),e.addClass("sb-drop-target")}),e.addEventListener("dragleave",()=>{e.removeClass("sb-drop-target")}),e.addEventListener("drop",async r=>{if(r.preventDefault(),e.removeClass("sb-drop-target"),!!r.dataTransfer)try{let s=JSON.parse(r.dataTransfer.getData("text/plain"));if(!s.id||!s.type)return;s.type==="bookmark"&&t.type==="folder"&&await this.store.moveBookmark(s.id,t.id)}catch(s){}})}};var I=class extends D.ItemView{constructor(t,r){super(t);this.browserPanel=null;this.bookmarkPanel=null;this.plugin=r}getViewType(){return m}getDisplayText(){return"Side Bookmark"}getIcon(){return"bookmark"}async onOpen(){let t=this.contentEl;t.empty(),t.addClass("sb-container"),this.browserPanel=new b(t),this.browserPanel.onAddBookmark=(s,o)=>{new p(this.app,this.plugin.store,s,o,null,async(i,l,c)=>{await this.plugin.store.addBookmark(i,l,c)}).open()};let r=this.plugin.store.defaultUrl;r&&this.browserPanel.navigate(r),this.bookmarkPanel=new E(this.app,t,this.plugin.store),this.bookmarkPanel.onNavigate=s=>{this.browserPanel&&this.browserPanel.navigate(s)},this.plugin.store.showBookmarkPanel||this.bookmarkPanel.toggleCollapse()}async onClose(){this.browserPanel&&(this.browserPanel.destroy(),this.browserPanel=null),this.bookmarkPanel&&(this.bookmarkPanel.destroy(),this.bookmarkPanel=null)}navigateTo(t){this.browserPanel&&this.browserPanel.navigate(t)}};var g=require("obsidian"),y=class extends g.PluginSettingTab{constructor(e,t){super(e,t),this.plugin=t}display(){let{containerEl:e}=this;e.empty(),e.createEl("h2",{text:"Side Bookmark \u8BBE\u7F6E"}),new g.Setting(e).setName("\u9ED8\u8BA4\u9996\u9875").setDesc("\u6253\u5F00\u63D2\u4EF6\u65F6\u9ED8\u8BA4\u52A0\u8F7D\u7684\u7F51\u5740").addText(r=>r.setPlaceholder("https://www.google.com").setValue(this.plugin.store.defaultUrl).onChange(async s=>{this.plugin.store.defaultUrl=s,await this.plugin.store.save()})),new g.Setting(e).setName("\u9ED8\u8BA4\u663E\u793A\u4E66\u7B7E\u9762\u677F").setDesc("\u6253\u5F00\u63D2\u4EF6\u65F6\u662F\u5426\u9ED8\u8BA4\u5C55\u5F00\u4E66\u7B7E\u5217\u8868\u9762\u677F").addToggle(r=>r.setValue(this.plugin.store.showBookmarkPanel).onChange(async s=>{this.plugin.store.showBookmarkPanel=s,await this.plugin.store.save()})),e.createEl("h3",{text:"\u7EDF\u8BA1"});let t=e.createDiv({cls:"sb-settings-stats"});t.createEl("p",{text:`\u4E66\u7B7E\u6570\u91CF: ${this.plugin.store.bookmarks.length}`}),t.createEl("p",{text:`\u6587\u4EF6\u5939\u6570\u91CF: ${this.plugin.store.folders.length}`})}};var T=class extends F.Plugin{async onload(){this.store=new f(this),await this.store.load(),this.registerView(m,e=>new I(e,this)),this.addRibbonIcon("bookmark","Side Bookmark",()=>{this.activateView()}),this.addCommand({id:"open-side-bookmark",name:"\u6253\u5F00\u4FA7\u8FB9\u680F\u4E66\u7B7E",callback:()=>{this.activateView()}}),this.addSettingTab(new y(this.app,this))}onunload(){this.app.workspace.detachLeavesOfType(m)}async activateView(){let{workspace:e}=this.app,t=e.getLeavesOfType(m);if(t.length>0)e.revealLeaf(t[0]);else{let r=e.getRightLeaf(!1);r&&(await r.setViewState({type:m,active:!0}),e.revealLeaf(r))}}}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..986864b --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "side-bookmark", + "name": "Side Bookmark", + "version": "0.0.1", + "minAppVersion": "0.15.0", + "description": "A sidebar bookmark browser for Obsidian. Browse websites and manage bookmarks right in the sidebar.", + "author": "Side Bookmark", + "isDesktopOnly": true +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..91bc48a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,614 @@ +{ + "name": "side-bookmark", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "side-bookmark", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "obsidian": "latest" + }, + "devDependencies": { + "@types/node": "^16.11.6", + "esbuild": "0.25.5", + "tslib": "2.4.0", + "typescript": "^5.8.3" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c21c62 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "side-bookmark", + "version": "0.0.1", + "description": "A sidebar bookmark browser plugin for Obsidian", + "main": "main.js", + "type": "module", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": [], + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "esbuild": "0.25.5", + "tslib": "2.4.0", + "typescript": "^5.8.3" + }, + "dependencies": { + "obsidian": "latest" + } +} diff --git a/src/BookmarkPanel.ts b/src/BookmarkPanel.ts new file mode 100644 index 0000000..08cdc25 --- /dev/null +++ b/src/BookmarkPanel.ts @@ -0,0 +1,417 @@ +/** + * Side Bookmark - Bookmark Panel + * Renders the bookmark tree list with folders, drag-and-drop, and context menus. + */ + +import { App, Menu, setIcon } from 'obsidian'; +import type { BookmarkStore } from './BookmarkStore'; +import type { BookmarkItem, BookmarkFolder, BookmarkTreeItem } from './types'; +import { + AddBookmarkModal, + AddFolderModal, + EditBookmarkModal, + EditFolderModal, + ConfirmDeleteModal, +} from './modals'; + +export class BookmarkPanel { + private app: App; + private containerEl: HTMLElement; + private headerEl: HTMLElement; + private listEl: HTMLElement; + private store: BookmarkStore; + private collapsed = false; + private unsubscribe: (() => void) | null = null; + + /** Callback when a bookmark is clicked */ + onNavigate: ((url: string) => void) | null = null; + + constructor(app: App, parentEl: HTMLElement, store: BookmarkStore) { + this.app = app; + this.store = store; + + this.containerEl = parentEl.createDiv({ cls: 'sb-bookmark-panel' }); + + // Header bar + this.headerEl = this.containerEl.createDiv({ cls: 'sb-bookmark-header' }); + + const headerLeft = this.headerEl.createDiv({ cls: 'sb-bookmark-header-left' }); + const collapseIcon = headerLeft.createDiv({ cls: 'sb-collapse-icon' }); + setIcon(collapseIcon, 'chevron-down'); + headerLeft.createSpan({ text: '书签', cls: 'sb-bookmark-title' }); + + headerLeft.addEventListener('click', () => { + this.toggleCollapse(); + }); + + // Action buttons + const headerRight = this.headerEl.createDiv({ cls: 'sb-bookmark-header-right' }); + + const addBtnGroup = headerRight.createDiv({ cls: 'sb-add-btn-group' }); + + const addBookmarkBtn = addBtnGroup.createDiv({ + cls: 'sb-header-btn', + attr: { 'aria-label': '添加书签' }, + }); + setIcon(addBookmarkBtn, 'plus'); + addBookmarkBtn.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + new AddBookmarkModal(this.app, this.store).open(); + }); + + const addFolderBtn = addBtnGroup.createDiv({ + cls: 'sb-header-btn', + attr: { 'aria-label': '新建文件夹' }, + }); + setIcon(addFolderBtn, 'folder-plus'); + addFolderBtn.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + new AddFolderModal(this.app, this.store).open(); + }); + + // Bookmark list container + this.listEl = this.containerEl.createDiv({ cls: 'sb-bookmark-list' }); + + // Subscribe to data changes + this.unsubscribe = this.store.onChange(() => { + this.renderList(); + }); + + // Initial render + this.renderList(); + } + + /** Toggle panel collapse/expand */ + toggleCollapse(): void { + this.collapsed = !this.collapsed; + this.containerEl.toggleClass('sb-collapsed', this.collapsed); + + const icon = this.headerEl.querySelector('.sb-collapse-icon'); + if (icon) { + icon.empty(); + setIcon(icon as HTMLElement, this.collapsed ? 'chevron-right' : 'chevron-down'); + } + } + + /** Clean up */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + } + this.containerEl.remove(); + } + + /** Render the full bookmark tree */ + private renderList(): void { + this.listEl.empty(); + + const rootItems = this.store.getTreeItems(null); + + if (rootItems.length === 0) { + const emptyState = this.listEl.createDiv({ cls: 'sb-empty-state' }); + const emptyIcon = emptyState.createDiv({ cls: 'sb-empty-icon' }); + setIcon(emptyIcon, 'bookmark'); + emptyState.createDiv({ text: '暂无书签', cls: 'sb-empty-text' }); + emptyState.createDiv({ + text: '点击 + 按钮或 ⭐ 按钮添加书签', + cls: 'sb-empty-hint', + }); + return; + } + + this.renderTreeLevel(this.listEl, null, 0); + } + + /** Recursively render a level of the bookmark tree */ + private renderTreeLevel(parentEl: HTMLElement, parentId: string | null, depth: number): void { + const items = this.store.getTreeItems(parentId); + + for (const item of items) { + if (item.type === 'folder') { + this.renderFolder(parentEl, item as BookmarkFolder, depth); + } else { + this.renderBookmark(parentEl, item as BookmarkItem, depth); + } + } + } + + /** Render a folder item */ + private renderFolder(parentEl: HTMLElement, folder: BookmarkFolder, depth: number): void { + const folderEl = parentEl.createDiv({ + cls: 'sb-tree-item sb-folder-item', + attr: { 'data-id': folder.id, 'data-type': 'folder', draggable: 'true' }, + }); + folderEl.style.paddingLeft = `${depth * 16 + 4}px`; + + const folderRow = folderEl.createDiv({ cls: 'sb-tree-item-row' }); + + // Collapse icon + const collapseIcon = folderRow.createDiv({ cls: 'sb-folder-collapse' }); + setIcon(collapseIcon, folder.collapsed ? 'chevron-right' : 'chevron-down'); + + // Folder icon + const folderIcon = folderRow.createDiv({ cls: 'sb-item-icon' }); + setIcon(folderIcon, folder.collapsed ? 'folder' : 'folder-open'); + + // Folder name + folderRow.createSpan({ text: folder.name, cls: 'sb-item-label' }); + + // Count badge + const childCount = this.store.getBookmarksByFolder(folder.id).length + + this.store.getSubFolders(folder.id).length; + if (childCount > 0) { + folderRow.createSpan({ text: `${childCount}`, cls: 'sb-count-badge' }); + } + + // Click to toggle collapse + folderRow.addEventListener('click', () => { + this.store.toggleFolder(folder.id); + }); + + // Context menu + folderRow.addEventListener('contextmenu', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.showFolderContextMenu(e, folder); + }); + + // Drag events + this.setupDragEvents(folderEl, folder); + + // Render children if not collapsed + if (!folder.collapsed) { + const childrenEl = parentEl.createDiv({ cls: 'sb-folder-children' }); + this.renderTreeLevel(childrenEl, folder.id, depth + 1); + } + } + + /** Render a bookmark item */ + private renderBookmark(parentEl: HTMLElement, bookmark: BookmarkItem, depth: number): void { + const bookmarkEl = parentEl.createDiv({ + cls: 'sb-tree-item sb-bookmark-item', + attr: { 'data-id': bookmark.id, 'data-type': 'bookmark', draggable: 'true' }, + }); + bookmarkEl.style.paddingLeft = `${depth * 16 + 24}px`; + + const bookmarkRow = bookmarkEl.createDiv({ cls: 'sb-tree-item-row' }); + + // Bookmark icon + const icon = bookmarkRow.createDiv({ cls: 'sb-item-icon' }); + setIcon(icon, 'globe'); + + // Bookmark title + bookmarkRow.createSpan({ text: bookmark.title, cls: 'sb-item-label' }); + + // Click to navigate + bookmarkRow.addEventListener('click', () => { + if (this.onNavigate) { + this.onNavigate(bookmark.url); + } + }); + + // Context menu + bookmarkRow.addEventListener('contextmenu', (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.showBookmarkContextMenu(e, bookmark); + }); + + // Drag events + this.setupDragEvents(bookmarkEl, bookmark); + } + + /** Show context menu for a bookmark */ + private showBookmarkContextMenu(event: MouseEvent, bookmark: BookmarkItem): void { + const menu = new Menu(); + + menu.addItem(item => { + item.setTitle('打开'); + item.setIcon('external-link'); + item.onClick(() => { + if (this.onNavigate) { + this.onNavigate(bookmark.url); + } + }); + }); + + menu.addSeparator(); + + menu.addItem(item => { + item.setTitle('编辑'); + item.setIcon('pencil'); + item.onClick(() => { + new EditBookmarkModal( + this.app, + this.store, + bookmark.id, + bookmark.title, + bookmark.url, + bookmark.folderId + ).open(); + }); + }); + + // Move to folder submenu + menu.addItem(item => { + item.setTitle('移动到...'); + item.setIcon('folder-input'); + item.onClick(() => { + this.showMoveMenu(event, bookmark); + }); + }); + + menu.addSeparator(); + + menu.addItem(item => { + item.setTitle('删除'); + item.setIcon('trash-2'); + item.onClick(() => { + new ConfirmDeleteModal( + this.app, + `确定要删除书签"${bookmark.title}"吗?`, + async () => { await this.store.removeBookmark(bookmark.id); } + ).open(); + }); + }); + + menu.showAtMouseEvent(event); + } + + /** Show context menu for a folder */ + private showFolderContextMenu(event: MouseEvent, folder: BookmarkFolder): void { + const menu = new Menu(); + + menu.addItem(item => { + item.setTitle('添加书签到此文件夹'); + item.setIcon('plus'); + item.onClick(() => { + new AddBookmarkModal(this.app, this.store, '', '', folder.id).open(); + }); + }); + + menu.addItem(item => { + item.setTitle('添加子文件夹'); + item.setIcon('folder-plus'); + item.onClick(() => { + new AddFolderModal(this.app, this.store, folder.id).open(); + }); + }); + + menu.addSeparator(); + + menu.addItem(item => { + item.setTitle('重命名'); + item.setIcon('pencil'); + item.onClick(() => { + new EditFolderModal(this.app, this.store, folder.id, folder.name).open(); + }); + }); + + menu.addSeparator(); + + menu.addItem(item => { + item.setTitle('删除文件夹'); + item.setIcon('trash-2'); + item.onClick(() => { + const bookmarkCount = this.store.getBookmarksByFolder(folder.id).length; + const subFolderCount = this.store.getSubFolders(folder.id).length; + let msg = `确定要删除文件夹"${folder.name}"吗?`; + if (bookmarkCount > 0 || subFolderCount > 0) { + msg += `\n其中包含 ${bookmarkCount} 个书签和 ${subFolderCount} 个子文件夹,将一并删除。`; + } + new ConfirmDeleteModal( + this.app, + msg, + async () => { await this.store.removeFolder(folder.id); } + ).open(); + }); + }); + + menu.showAtMouseEvent(event); + } + + /** Show a submenu to move a bookmark to a folder */ + private showMoveMenu(event: MouseEvent, bookmark: BookmarkItem): void { + const menu = new Menu(); + + // Move to root + if (bookmark.folderId !== null) { + menu.addItem(item => { + item.setTitle('(根级别)'); + item.setIcon('corner-left-up'); + item.onClick(async () => { + await this.store.moveBookmark(bookmark.id, null); + }); + }); + menu.addSeparator(); + } + + // Move to each folder + for (const folder of this.store.folders) { + if (folder.id !== bookmark.folderId) { + menu.addItem(item => { + item.setTitle(folder.name); + item.setIcon('folder'); + item.onClick(async () => { + await this.store.moveBookmark(bookmark.id, folder.id); + }); + }); + } + } + + menu.showAtMouseEvent(event); + } + + /** Set up drag and drop on a tree item */ + private setupDragEvents(el: HTMLElement, item: BookmarkTreeItem): void { + el.addEventListener('dragstart', (e: DragEvent) => { + if (e.dataTransfer) { + e.dataTransfer.setData('text/plain', JSON.stringify({ + id: item.id, + type: item.type, + })); + e.dataTransfer.effectAllowed = 'move'; + } + el.addClass('sb-dragging'); + }); + + el.addEventListener('dragend', () => { + el.removeClass('sb-dragging'); + // Remove all drop indicators + this.listEl.querySelectorAll('.sb-drop-target').forEach( + el => el.removeClass('sb-drop-target') + ); + }); + + el.addEventListener('dragover', (e: DragEvent) => { + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'move'; + } + el.addClass('sb-drop-target'); + }); + + el.addEventListener('dragleave', () => { + el.removeClass('sb-drop-target'); + }); + + el.addEventListener('drop', async (e: DragEvent) => { + e.preventDefault(); + el.removeClass('sb-drop-target'); + + if (!e.dataTransfer) return; + + try { + const data = JSON.parse(e.dataTransfer.getData('text/plain')); + if (!data.id || !data.type) return; + + // If dropping a bookmark onto a folder, move it + if (data.type === 'bookmark' && item.type === 'folder') { + await this.store.moveBookmark(data.id, item.id); + } + } catch { + // Ignore invalid data + } + }); + } +} diff --git a/src/BookmarkStore.ts b/src/BookmarkStore.ts new file mode 100644 index 0000000..de8d11f --- /dev/null +++ b/src/BookmarkStore.ts @@ -0,0 +1,242 @@ +/** + * Side Bookmark - Data Management Layer + * Handles CRUD operations and persistence for bookmarks and folders. + */ + +import type SideBookmarkPlugin from './main'; +import { + type BookmarkItem, + type BookmarkFolder, + type BookmarkTreeItem, + type SideBookmarkData, + DEFAULT_DATA, + generateId, +} from './types'; + +export class BookmarkStore { + private plugin: SideBookmarkPlugin; + private data: SideBookmarkData; + private listeners: Array<() => void> = []; + + constructor(plugin: SideBookmarkPlugin) { + this.plugin = plugin; + this.data = { ...DEFAULT_DATA }; + } + + /** Load data from Obsidian's persistent storage */ + async load(): Promise { + const saved = await this.plugin.loadData() as Partial | null; + if (saved) { + this.data = { + ...DEFAULT_DATA, + ...saved, + }; + } + } + + /** Save data to Obsidian's persistent storage */ + async save(): Promise { + await this.plugin.saveData(this.data); + } + + /** Subscribe to data changes */ + onChange(listener: () => void): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter(l => l !== listener); + }; + } + + /** Notify all listeners of data changes */ + private notify(): void { + for (const listener of this.listeners) { + listener(); + } + } + + // ── Getters ────────────────────────────────────────────── + + get defaultUrl(): string { + return this.data.defaultUrl; + } + + set defaultUrl(url: string) { + this.data.defaultUrl = url; + } + + get showBookmarkPanel(): boolean { + return this.data.showBookmarkPanel; + } + + set showBookmarkPanel(show: boolean) { + this.data.showBookmarkPanel = show; + } + + get bookmarks(): BookmarkItem[] { + return this.data.bookmarks; + } + + get folders(): BookmarkFolder[] { + return this.data.folders; + } + + // ── Bookmark CRUD ──────────────────────────────────────── + + /** Add a new bookmark */ + async addBookmark(title: string, url: string, folderId: string | null = null): Promise { + const siblings = this.data.bookmarks.filter(b => b.folderId === folderId); + const maxOrder = siblings.length > 0 + ? Math.max(...siblings.map(b => b.order)) + : -1; + + const bookmark: BookmarkItem = { + id: generateId(), + type: 'bookmark', + title, + url, + folderId, + order: maxOrder + 1, + }; + this.data.bookmarks.push(bookmark); + await this.save(); + this.notify(); + return bookmark; + } + + /** Remove a bookmark by ID */ + async removeBookmark(id: string): Promise { + this.data.bookmarks = this.data.bookmarks.filter(b => b.id !== id); + await this.save(); + this.notify(); + } + + /** Update a bookmark's properties */ + async updateBookmark(id: string, updates: Partial>): Promise { + const bookmark = this.data.bookmarks.find(b => b.id === id); + if (bookmark) { + Object.assign(bookmark, updates); + await this.save(); + this.notify(); + } + } + + /** Move a bookmark to a different folder */ + async moveBookmark(id: string, targetFolderId: string | null): Promise { + await this.updateBookmark(id, { folderId: targetFolderId }); + } + + // ── Folder CRUD ────────────────────────────────────────── + + /** Add a new folder */ + async addFolder(name: string, parentId: string | null = null): Promise { + const siblings = this.data.folders.filter(f => f.parentId === parentId); + const maxOrder = siblings.length > 0 + ? Math.max(...siblings.map(f => f.order)) + : -1; + + const folder: BookmarkFolder = { + id: generateId(), + type: 'folder', + name, + parentId, + collapsed: false, + order: maxOrder + 1, + }; + this.data.folders.push(folder); + await this.save(); + this.notify(); + return folder; + } + + /** Remove a folder and all its contents (bookmarks + sub-folders) recursively */ + async removeFolder(id: string): Promise { + // Collect all descendant folder IDs + const descendantIds = this.getDescendantFolderIds(id); + const allFolderIds = [id, ...descendantIds]; + + // Remove all bookmarks in these folders + this.data.bookmarks = this.data.bookmarks.filter( + b => !allFolderIds.includes(b.folderId ?? '') + ); + + // Remove all folders + this.data.folders = this.data.folders.filter( + f => !allFolderIds.includes(f.id) + ); + + await this.save(); + this.notify(); + } + + /** Update a folder's properties */ + async updateFolder(id: string, updates: Partial>): Promise { + const folder = this.data.folders.find(f => f.id === id); + if (folder) { + Object.assign(folder, updates); + await this.save(); + this.notify(); + } + } + + /** Toggle a folder's collapsed state */ + async toggleFolder(id: string): Promise { + const folder = this.data.folders.find(f => f.id === id); + if (folder) { + folder.collapsed = !folder.collapsed; + await this.save(); + this.notify(); + } + } + + // ── Query Methods ──────────────────────────────────────── + + /** Get all bookmarks in a specific folder */ + getBookmarksByFolder(folderId: string | null): BookmarkItem[] { + return this.data.bookmarks + .filter(b => b.folderId === folderId) + .sort((a, b) => a.order - b.order); + } + + /** Get all sub-folders of a specific parent folder */ + getSubFolders(parentId: string | null): BookmarkFolder[] { + return this.data.folders + .filter(f => f.parentId === parentId) + .sort((a, b) => a.order - b.order); + } + + /** Get a folder by ID */ + getFolder(id: string): BookmarkFolder | undefined { + return this.data.folders.find(f => f.id === id); + } + + /** Get a bookmark by ID */ + getBookmark(id: string): BookmarkItem | undefined { + return this.data.bookmarks.find(b => b.id === id); + } + + /** Get tree structure items for a given parent */ + getTreeItems(parentId: string | null): BookmarkTreeItem[] { + const folders = this.getSubFolders(parentId); + const bookmarks = this.getBookmarksByFolder(parentId); + const items: BookmarkTreeItem[] = [...folders, ...bookmarks]; + return items.sort((a, b) => a.order - b.order); + } + + /** Check if a bookmark with the same URL already exists */ + hasBookmarkWithUrl(url: string): boolean { + return this.data.bookmarks.some(b => b.url === url); + } + + // ── Private Helpers ────────────────────────────────────── + + /** Recursively get all descendant folder IDs */ + private getDescendantFolderIds(folderId: string): string[] { + const children = this.data.folders.filter(f => f.parentId === folderId); + const ids: string[] = []; + for (const child of children) { + ids.push(child.id); + ids.push(...this.getDescendantFolderIds(child.id)); + } + return ids; + } +} diff --git a/src/BookmarkView.ts b/src/BookmarkView.ts new file mode 100644 index 0000000..176e696 --- /dev/null +++ b/src/BookmarkView.ts @@ -0,0 +1,96 @@ +/** + * Side Bookmark - Main View + * The ItemView subclass registered as the right sidebar panel. + */ + +import { ItemView, WorkspaceLeaf } from 'obsidian'; +import { VIEW_TYPE_SIDE_BOOKMARK } from './types'; +import { BrowserPanel } from './BrowserPanel'; +import { BookmarkPanel } from './BookmarkPanel'; +import { AddBookmarkModal } from './modals'; +import type SideBookmarkPlugin from './main'; + +export class BookmarkView extends ItemView { + private plugin: SideBookmarkPlugin; + private browserPanel: BrowserPanel | null = null; + private bookmarkPanel: BookmarkPanel | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: SideBookmarkPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return VIEW_TYPE_SIDE_BOOKMARK; + } + + getDisplayText(): string { + return 'Side Bookmark'; + } + + getIcon(): string { + return 'bookmark'; + } + + async onOpen(): Promise { + const container = this.contentEl; + container.empty(); + container.addClass('sb-container'); + + // Create browser panel (navigation bar + webview) + this.browserPanel = new BrowserPanel(container); + + // Set up the "add bookmark" callback from browser panel + this.browserPanel.onAddBookmark = (title: string, url: string) => { + new AddBookmarkModal( + this.app, + this.plugin.store, + title, + url, + null, + async (savedTitle: string, savedUrl: string, folderId: string | null) => { + await this.plugin.store.addBookmark(savedTitle, savedUrl, folderId); + } + ).open(); + }; + + // Navigate to default URL + const defaultUrl = this.plugin.store.defaultUrl; + if (defaultUrl) { + this.browserPanel.navigate(defaultUrl); + } + + // Create bookmark panel + this.bookmarkPanel = new BookmarkPanel(this.app, container, this.plugin.store); + + // Set up navigation callback from bookmark panel + this.bookmarkPanel.onNavigate = (url: string) => { + if (this.browserPanel) { + this.browserPanel.navigate(url); + } + }; + + // If bookmark panel should be collapsed by default + if (!this.plugin.store.showBookmarkPanel) { + this.bookmarkPanel.toggleCollapse(); + } + } + + async onClose(): Promise { + if (this.browserPanel) { + this.browserPanel.destroy(); + this.browserPanel = null; + } + if (this.bookmarkPanel) { + this.bookmarkPanel.destroy(); + this.bookmarkPanel = null; + } + } + + /** Navigate the browser panel to a URL (used by external callers) */ + navigateTo(url: string): void { + if (this.browserPanel) { + this.browserPanel.navigate(url); + } + } +} diff --git a/src/BrowserPanel.ts b/src/BrowserPanel.ts new file mode 100644 index 0000000..f6f0c5e --- /dev/null +++ b/src/BrowserPanel.ts @@ -0,0 +1,202 @@ +/** + * Side Bookmark - Browser Panel + * Manages the webview element and navigation bar for web browsing. + */ + +import { setIcon } from 'obsidian'; + +export class BrowserPanel { + private containerEl: HTMLElement; + private navBarEl: HTMLElement; + private webviewContainer: HTMLElement; + private webviewEl: HTMLElement | null = null; + private urlInput: HTMLInputElement; + private backBtn: HTMLElement; + private forwardBtn: HTMLElement; + private refreshBtn: HTMLElement; + private bookmarkBtn: HTMLElement; + + private currentUrl = ''; + private currentTitle = ''; + + /** Callback when user wants to add current page as bookmark */ + onAddBookmark: ((title: string, url: string) => void) | null = null; + + constructor(parentEl: HTMLElement) { + this.containerEl = parentEl.createDiv({ cls: 'sb-browser-panel' }); + + // Build the navigation bar + this.navBarEl = this.containerEl.createDiv({ cls: 'sb-nav-bar' }); + + // Navigation buttons + const navBtns = this.navBarEl.createDiv({ cls: 'sb-nav-buttons' }); + + this.backBtn = navBtns.createDiv({ cls: 'sb-nav-btn', attr: { 'aria-label': '后退' } }); + setIcon(this.backBtn, 'arrow-left'); + this.backBtn.addEventListener('click', () => this.goBack()); + + this.forwardBtn = navBtns.createDiv({ cls: 'sb-nav-btn', attr: { 'aria-label': '前进' } }); + setIcon(this.forwardBtn, 'arrow-right'); + this.forwardBtn.addEventListener('click', () => this.goForward()); + + this.refreshBtn = navBtns.createDiv({ cls: 'sb-nav-btn', attr: { 'aria-label': '刷新' } }); + setIcon(this.refreshBtn, 'refresh-cw'); + this.refreshBtn.addEventListener('click', () => this.reload()); + + // URL input + this.urlInput = this.navBarEl.createEl('input', { + cls: 'sb-url-input', + attr: { + type: 'text', + placeholder: '输入网址...', + spellcheck: 'false', + }, + }); + this.urlInput.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') { + this.navigateToInput(); + } + }); + this.urlInput.addEventListener('focus', () => { + this.urlInput.select(); + }); + + // Bookmark (star) button + this.bookmarkBtn = this.navBarEl.createDiv({ cls: 'sb-nav-btn sb-bookmark-btn', attr: { 'aria-label': '添加书签' } }); + setIcon(this.bookmarkBtn, 'star'); + this.bookmarkBtn.addEventListener('click', () => { + if (this.onAddBookmark && this.currentUrl) { + this.onAddBookmark(this.currentTitle || this.currentUrl, this.currentUrl); + } + }); + + // Webview container + this.webviewContainer = this.containerEl.createDiv({ cls: 'sb-webview-container' }); + } + + /** Navigate to a URL */ + navigate(url: string): void { + let normalizedUrl = url.trim(); + if (!normalizedUrl) return; + + if (!/^https?:\/\//i.test(normalizedUrl)) { + normalizedUrl = 'https://' + normalizedUrl; + } + + this.currentUrl = normalizedUrl; + this.urlInput.value = normalizedUrl; + + this.createOrUpdateWebview(normalizedUrl); + } + + /** Go back in browser history */ + goBack(): void { + if (this.webviewEl) { + (this.webviewEl as any).goBack?.(); + } + } + + /** Go forward in browser history */ + goForward(): void { + if (this.webviewEl) { + (this.webviewEl as any).goForward?.(); + } + } + + /** Reload the current page */ + reload(): void { + if (this.webviewEl) { + (this.webviewEl as any).reload?.(); + } + } + + /** Get current URL */ + getUrl(): string { + return this.currentUrl; + } + + /** Get current page title */ + getTitle(): string { + return this.currentTitle; + } + + /** Clean up */ + destroy(): void { + if (this.webviewEl) { + this.webviewEl.remove(); + this.webviewEl = null; + } + this.containerEl.remove(); + } + + /** Navigate to the URL in the input field */ + private navigateToInput(): void { + const url = this.urlInput.value.trim(); + if (url) { + this.navigate(url); + } + } + + /** Create or update the webview element */ + private createOrUpdateWebview(url: string): void { + // Remove existing webview + if (this.webviewEl) { + this.webviewEl.remove(); + } + + // Create a new webview element (Electron's webview tag) + const webview = document.createElement('webview') as HTMLElement; + + webview.setAttribute('src', url); + webview.setAttribute('allowpopups', ''); + webview.addClass('sb-webview'); + + // Listen for navigation events + webview.addEventListener('did-navigate', ((e: CustomEvent) => { + const detail = e as any; + if (detail.url) { + this.currentUrl = detail.url; + this.urlInput.value = detail.url; + } + }) as EventListener); + + webview.addEventListener('did-navigate-in-page', ((e: CustomEvent) => { + const detail = e as any; + if (detail.url) { + this.currentUrl = detail.url; + this.urlInput.value = detail.url; + } + }) as EventListener); + + webview.addEventListener('page-title-updated', ((e: CustomEvent) => { + const detail = e as any; + if (detail.title) { + this.currentTitle = detail.title; + } + }) as EventListener); + + // Use dom-ready to set up more event listeners via webview API + webview.addEventListener('dom-ready', () => { + // Update URL from webview's actual URL + const wv = webview as any; + if (wv.getURL) { + this.currentUrl = wv.getURL(); + this.urlInput.value = this.currentUrl; + } + if (wv.getTitle) { + this.currentTitle = wv.getTitle(); + } + }); + + // Handle new-window events (open links in same webview) + webview.addEventListener('new-window', ((e: any) => { + if (e.url) { + this.navigate(e.url); + } + }) as EventListener); + + this.webviewContainer.empty(); + this.webviewContainer.appendChild(webview); + this.webviewEl = webview; + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..838dd89 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,71 @@ +/** + * Side Bookmark - Main Plugin Entry + * Registers the view, ribbon icon, and commands. + */ + +import { Plugin, WorkspaceLeaf } from 'obsidian'; +import { VIEW_TYPE_SIDE_BOOKMARK } from './types'; +import { BookmarkStore } from './BookmarkStore'; +import { BookmarkView } from './BookmarkView'; +import { SideBookmarkSettingTab } from './settings'; + +export default class SideBookmarkPlugin extends Plugin { + store: BookmarkStore; + + async onload(): Promise { + // Initialize data store + this.store = new BookmarkStore(this); + await this.store.load(); + + // Register the custom view + this.registerView( + VIEW_TYPE_SIDE_BOOKMARK, + (leaf) => new BookmarkView(leaf, this) + ); + + // Add ribbon icon (left sidebar) + this.addRibbonIcon('bookmark', 'Side Bookmark', () => { + this.activateView(); + }); + + // Add command to open the view + this.addCommand({ + id: 'open-side-bookmark', + name: '打开侧边栏书签', + callback: () => { + this.activateView(); + }, + }); + + // Add settings tab + this.addSettingTab(new SideBookmarkSettingTab(this.app, this)); + } + + onunload(): void { + // Detach all leaves of our view type + this.app.workspace.detachLeavesOfType(VIEW_TYPE_SIDE_BOOKMARK); + } + + /** Open or reveal the Side Bookmark panel in the right sidebar */ + async activateView(): Promise { + const { workspace } = this.app; + + // Check if a leaf with our view already exists + const leaves = workspace.getLeavesOfType(VIEW_TYPE_SIDE_BOOKMARK); + + if (leaves.length > 0) { + // View already exists, reveal it + workspace.revealLeaf(leaves[0] as WorkspaceLeaf); + } else { + // Create a new leaf in the right sidebar + const leaf = workspace.getRightLeaf(false); + if (leaf) { + await leaf.setViewState({ + type: VIEW_TYPE_SIDE_BOOKMARK, + active: true, + }); + workspace.revealLeaf(leaf); + } + } + } +} diff --git a/src/modals.ts b/src/modals.ts new file mode 100644 index 0000000..9fc4855 --- /dev/null +++ b/src/modals.ts @@ -0,0 +1,394 @@ +/** + * Side Bookmark - Modal Dialogs + * Provides dialogs for adding/editing bookmarks and folders. + */ + +import { App, Modal, Setting } from 'obsidian'; +import type { BookmarkFolder } from './types'; +import type { BookmarkStore } from './BookmarkStore'; + +// ── Add Bookmark Modal ────────────────────────────────────── + +export class AddBookmarkModal extends Modal { + private title: string; + private url: string; + private folderId: string | null; + private store: BookmarkStore; + private onSave: ((title: string, url: string, folderId: string | null) => void) | null; + + constructor( + app: App, + store: BookmarkStore, + defaultTitle = '', + defaultUrl = '', + defaultFolderId: string | null = null, + onSave?: (title: string, url: string, folderId: string | null) => void + ) { + super(app); + this.store = store; + this.title = defaultTitle; + this.url = defaultUrl; + this.folderId = defaultFolderId; + this.onSave = onSave ?? null; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('side-bookmark-modal'); + + contentEl.createEl('h3', { text: '添加书签' }); + + new Setting(contentEl) + .setName('标题') + .addText(text => text + .setPlaceholder('输入书签标题') + .setValue(this.title) + .onChange(value => { this.title = value; }) + ); + + new Setting(contentEl) + .setName('网址') + .addText(text => text + .setPlaceholder('https://example.com') + .setValue(this.url) + .onChange(value => { this.url = value; }) + ); + + new Setting(contentEl) + .setName('文件夹') + .addDropdown(dropdown => { + dropdown.addOption('__root__', '(无文件夹)'); + for (const folder of this.store.folders) { + dropdown.addOption(folder.id, this.getFolderPath(folder)); + } + dropdown.setValue(this.folderId ?? '__root__'); + dropdown.onChange(value => { + this.folderId = value === '__root__' ? null : value; + }); + }); + + new Setting(contentEl) + .addButton(btn => btn + .setButtonText('保存') + .setCta() + .onClick(async () => { + if (!this.url.trim()) { + return; + } + if (!this.title.trim()) { + this.title = this.url; + } + // Ensure URL has protocol + let url = this.url.trim(); + if (!/^https?:\/\//i.test(url)) { + url = 'https://' + url; + } + if (this.onSave) { + this.onSave(this.title.trim(), url, this.folderId); + } else { + await this.store.addBookmark(this.title.trim(), url, this.folderId); + } + this.close(); + }) + ) + .addButton(btn => btn + .setButtonText('取消') + .onClick(() => this.close()) + ); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } + + /** Build a display path for a folder (e.g., "Parent / Child") */ + private getFolderPath(folder: BookmarkFolder): string { + const parts: string[] = [folder.name]; + let current: BookmarkFolder | undefined = folder; + while (current?.parentId) { + current = this.store.getFolder(current.parentId); + if (current) { + parts.unshift(current.name); + } + } + return parts.join(' / '); + } +} + +// ── Edit Bookmark Modal ───────────────────────────────────── + +export class EditBookmarkModal extends Modal { + private title: string; + private url: string; + private folderId: string | null; + private bookmarkId: string; + private store: BookmarkStore; + + constructor( + app: App, + store: BookmarkStore, + bookmarkId: string, + currentTitle: string, + currentUrl: string, + currentFolderId: string | null + ) { + super(app); + this.store = store; + this.bookmarkId = bookmarkId; + this.title = currentTitle; + this.url = currentUrl; + this.folderId = currentFolderId; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('side-bookmark-modal'); + + contentEl.createEl('h3', { text: '编辑书签' }); + + new Setting(contentEl) + .setName('标题') + .addText(text => text + .setPlaceholder('输入书签标题') + .setValue(this.title) + .onChange(value => { this.title = value; }) + ); + + new Setting(contentEl) + .setName('网址') + .addText(text => text + .setPlaceholder('https://example.com') + .setValue(this.url) + .onChange(value => { this.url = value; }) + ); + + new Setting(contentEl) + .setName('文件夹') + .addDropdown(dropdown => { + dropdown.addOption('__root__', '(无文件夹)'); + for (const folder of this.store.folders) { + dropdown.addOption(folder.id, this.getFolderPath(folder)); + } + dropdown.setValue(this.folderId ?? '__root__'); + dropdown.onChange(value => { + this.folderId = value === '__root__' ? null : value; + }); + }); + + new Setting(contentEl) + .addButton(btn => btn + .setButtonText('保存') + .setCta() + .onClick(async () => { + if (!this.url.trim()) return; + if (!this.title.trim()) this.title = this.url; + + let url = this.url.trim(); + if (!/^https?:\/\//i.test(url)) { + url = 'https://' + url; + } + + await this.store.updateBookmark(this.bookmarkId, { + title: this.title.trim(), + url, + folderId: this.folderId, + }); + this.close(); + }) + ) + .addButton(btn => btn + .setButtonText('取消') + .onClick(() => this.close()) + ); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } + + private getFolderPath(folder: BookmarkFolder): string { + const parts: string[] = [folder.name]; + let current: BookmarkFolder | undefined = folder; + while (current?.parentId) { + current = this.store.getFolder(current.parentId); + if (current) { + parts.unshift(current.name); + } + } + return parts.join(' / '); + } +} + +// ── Add Folder Modal ──────────────────────────────────────── + +export class AddFolderModal extends Modal { + private folderName: string; + private parentId: string | null; + private store: BookmarkStore; + + constructor(app: App, store: BookmarkStore, defaultParentId: string | null = null) { + super(app); + this.store = store; + this.folderName = ''; + this.parentId = defaultParentId; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('side-bookmark-modal'); + + contentEl.createEl('h3', { text: '新建文件夹' }); + + new Setting(contentEl) + .setName('文件夹名称') + .addText(text => text + .setPlaceholder('输入文件夹名称') + .setValue(this.folderName) + .onChange(value => { this.folderName = value; }) + ); + + new Setting(contentEl) + .setName('父文件夹') + .addDropdown(dropdown => { + dropdown.addOption('__root__', '(根级别)'); + for (const folder of this.store.folders) { + dropdown.addOption(folder.id, this.getFolderPath(folder)); + } + dropdown.setValue(this.parentId ?? '__root__'); + dropdown.onChange(value => { + this.parentId = value === '__root__' ? null : value; + }); + }); + + new Setting(contentEl) + .addButton(btn => btn + .setButtonText('创建') + .setCta() + .onClick(async () => { + if (!this.folderName.trim()) return; + await this.store.addFolder(this.folderName.trim(), this.parentId); + this.close(); + }) + ) + .addButton(btn => btn + .setButtonText('取消') + .onClick(() => this.close()) + ); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } + + private getFolderPath(folder: BookmarkFolder): string { + const parts: string[] = [folder.name]; + let current: BookmarkFolder | undefined = folder; + while (current?.parentId) { + current = this.store.getFolder(current.parentId); + if (current) { + parts.unshift(current.name); + } + } + return parts.join(' / '); + } +} + +// ── Edit Folder Modal ─────────────────────────────────────── + +export class EditFolderModal extends Modal { + private folderName: string; + private folderId: string; + private store: BookmarkStore; + + constructor(app: App, store: BookmarkStore, folderId: string, currentName: string) { + super(app); + this.store = store; + this.folderId = folderId; + this.folderName = currentName; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('side-bookmark-modal'); + + contentEl.createEl('h3', { text: '编辑文件夹' }); + + new Setting(contentEl) + .setName('文件夹名称') + .addText(text => text + .setPlaceholder('输入文件夹名称') + .setValue(this.folderName) + .onChange(value => { this.folderName = value; }) + ); + + new Setting(contentEl) + .addButton(btn => btn + .setButtonText('保存') + .setCta() + .onClick(async () => { + if (!this.folderName.trim()) return; + await this.store.updateFolder(this.folderId, { + name: this.folderName.trim(), + }); + this.close(); + }) + ) + .addButton(btn => btn + .setButtonText('取消') + .onClick(() => this.close()) + ); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} + +// ── Confirm Delete Modal ──────────────────────────────────── + +export class ConfirmDeleteModal extends Modal { + private message: string; + private onConfirm: () => Promise; + + constructor(app: App, message: string, onConfirm: () => Promise) { + super(app); + this.message = message; + this.onConfirm = onConfirm; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('side-bookmark-modal'); + + contentEl.createEl('h3', { text: '确认删除' }); + contentEl.createEl('p', { text: this.message }); + + new Setting(contentEl) + .addButton(btn => btn + .setButtonText('删除') + .setWarning() + .onClick(async () => { + await this.onConfirm(); + this.close(); + }) + ) + .addButton(btn => btn + .setButtonText('取消') + .onClick(() => this.close()) + ); + } + + onClose(): void { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..08dda62 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,57 @@ +/** + * Side Bookmark - Settings Tab + * Plugin settings page in Obsidian's settings panel. + */ + +import { App, PluginSettingTab, Setting } from 'obsidian'; +import type SideBookmarkPlugin from './main'; + +export class SideBookmarkSettingTab extends PluginSettingTab { + plugin: SideBookmarkPlugin; + + constructor(app: App, plugin: SideBookmarkPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl('h2', { text: 'Side Bookmark 设置' }); + + new Setting(containerEl) + .setName('默认首页') + .setDesc('打开插件时默认加载的网址') + .addText(text => text + .setPlaceholder('https://www.google.com') + .setValue(this.plugin.store.defaultUrl) + .onChange(async (value) => { + this.plugin.store.defaultUrl = value; + await this.plugin.store.save(); + }) + ); + + new Setting(containerEl) + .setName('默认显示书签面板') + .setDesc('打开插件时是否默认展开书签列表面板') + .addToggle(toggle => toggle + .setValue(this.plugin.store.showBookmarkPanel) + .onChange(async (value) => { + this.plugin.store.showBookmarkPanel = value; + await this.plugin.store.save(); + }) + ); + + // Statistics section + containerEl.createEl('h3', { text: '统计' }); + + const stats = containerEl.createDiv({ cls: 'sb-settings-stats' }); + stats.createEl('p', { + text: `书签数量: ${this.plugin.store.bookmarks.length}`, + }); + stats.createEl('p', { + text: `文件夹数量: ${this.plugin.store.folders.length}`, + }); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f0ee222 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,52 @@ +/** + * Side Bookmark - Type Definitions + */ + +/** A single bookmark entry */ +export interface BookmarkItem { + id: string; + type: 'bookmark'; + title: string; + url: string; + /** null means root level (no folder) */ + folderId: string | null; + order: number; +} + +/** A bookmark folder that can contain bookmarks or sub-folders */ +export interface BookmarkFolder { + id: string; + type: 'folder'; + name: string; + /** null means root level */ + parentId: string | null; + collapsed: boolean; + order: number; +} + +/** Union type for tree rendering */ +export type BookmarkTreeItem = BookmarkItem | BookmarkFolder; + +/** Plugin persistent data */ +export interface SideBookmarkData { + bookmarks: BookmarkItem[]; + folders: BookmarkFolder[]; + defaultUrl: string; + showBookmarkPanel: boolean; +} + +/** Default data for first-time plugin load */ +export const DEFAULT_DATA: SideBookmarkData = { + bookmarks: [], + folders: [], + defaultUrl: 'https://www.google.com', + showBookmarkPanel: true, +}; + +/** View type constant */ +export const VIEW_TYPE_SIDE_BOOKMARK = 'side-bookmark-view'; + +/** Generate a unique ID */ +export function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2, 9); +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..39d8ef2 --- /dev/null +++ b/styles.css @@ -0,0 +1,425 @@ +/* ============================================================ + Side Bookmark - Plugin Styles + Uses Obsidian CSS variables for theme compatibility. + ============================================================ */ + +/* ── Main Container ──────────────────────────────────────── */ + +.sb-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* ── Browser Panel ───────────────────────────────────────── */ + +.sb-browser-panel { + display: flex; + flex-direction: column; + flex: 1; + min-height: 200px; + overflow: hidden; +} + +/* Navigation Bar */ +.sb-nav-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: var(--background-secondary); + border-bottom: 1px solid var(--background-modifier-border); + flex-shrink: 0; +} + +.sb-nav-buttons { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.sb-nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + cursor: pointer; + color: var(--text-muted); + transition: all 0.15s ease; +} + +.sb-nav-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.sb-nav-btn:active { + background: var(--background-modifier-active-hover); + transform: scale(0.95); +} + +.sb-nav-btn svg { + width: 16px; + height: 16px; +} + +.sb-bookmark-btn:hover { + color: var(--text-accent); +} + +/* URL Input */ +.sb-url-input { + flex: 1; + min-width: 0; + height: 28px; + padding: 0 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + color: var(--text-normal); + font-size: 12px; + font-family: var(--font-monospace); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.sb-url-input:focus { + border-color: var(--interactive-accent); + box-shadow: 0 0 0 2px rgba(var(--interactive-accent-rgb), 0.2); +} + +.sb-url-input::placeholder { + color: var(--text-faint); +} + +/* Webview Container */ +.sb-webview-container { + flex: 1; + overflow: hidden; + position: relative; + background: var(--background-primary); +} + +.sb-webview { + width: 100%; + height: 100%; + border: none; +} + +/* ── Bookmark Panel ──────────────────────────────────────── */ + +.sb-bookmark-panel { + display: flex; + flex-direction: column; + flex-shrink: 0; + max-height: 50%; + border-top: 2px solid var(--background-modifier-border); + background: var(--background-primary); + transition: max-height 0.25s ease; +} + +.sb-bookmark-panel.sb-collapsed { + max-height: 36px; +} + +.sb-bookmark-panel.sb-collapsed .sb-bookmark-list { + display: none; +} + +/* Bookmark Header */ +.sb-bookmark-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + background: var(--background-secondary); + flex-shrink: 0; + user-select: none; +} + +.sb-bookmark-header-left { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + flex: 1; + min-width: 0; +} + +.sb-bookmark-header-left:hover { + color: var(--text-accent); +} + +.sb-collapse-icon { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.sb-collapse-icon svg { + width: 14px; + height: 14px; +} + +.sb-bookmark-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.sb-bookmark-header-right { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + +.sb-add-btn-group { + display: flex; + align-items: center; + gap: 2px; +} + +.sb-header-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + transition: all 0.15s ease; +} + +.sb-header-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-accent); +} + +.sb-header-btn svg { + width: 14px; + height: 14px; +} + +/* Bookmark List */ +.sb-bookmark-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 4px 0; +} + +.sb-bookmark-list::-webkit-scrollbar { + width: 6px; +} + +.sb-bookmark-list::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 3px; +} + +.sb-bookmark-list::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); +} + +/* ── Tree Items ──────────────────────────────────────────── */ + +.sb-tree-item { + transition: background 0.1s ease; +} + +.sb-tree-item-row { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + cursor: pointer; + border-radius: 4px; + margin: 1px 4px; + min-height: 28px; + transition: background 0.1s ease; +} + +.sb-tree-item-row:hover { + background: var(--background-modifier-hover); +} + +.sb-tree-item-row:active { + background: var(--background-modifier-active-hover); +} + +/* Folder specific */ +.sb-folder-collapse { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--text-muted); +} + +.sb-folder-collapse svg { + width: 12px; + height: 12px; +} + +.sb-folder-item > .sb-tree-item-row { + font-weight: 500; +} + +.sb-folder-children { + /* Smooth expand/collapse could be added with max-height animation */ +} + +/* Item Icon */ +.sb-item-icon { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--text-muted); +} + +.sb-item-icon svg { + width: 14px; + height: 14px; +} + +.sb-folder-item .sb-item-icon { + color: var(--text-accent); +} + +.sb-bookmark-item .sb-item-icon { + color: var(--text-muted); +} + +/* Item Label */ +.sb-item-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + color: var(--text-normal); +} + +/* Count Badge */ +.sb-count-badge { + flex-shrink: 0; + font-size: 10px; + color: var(--text-faint); + background: var(--background-modifier-hover); + padding: 1px 6px; + border-radius: 10px; + line-height: 1.4; +} + +/* ── Empty State ─────────────────────────────────────────── */ + +.sb-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 16px; + gap: 8px; +} + +.sb-empty-icon { + color: var(--text-faint); + opacity: 0.5; +} + +.sb-empty-icon svg { + width: 32px; + height: 32px; +} + +.sb-empty-text { + font-size: 14px; + color: var(--text-muted); + font-weight: 500; +} + +.sb-empty-hint { + font-size: 12px; + color: var(--text-faint); +} + +/* ── Drag and Drop ───────────────────────────────────────── */ + +.sb-dragging { + opacity: 0.4; +} + +.sb-drop-target > .sb-tree-item-row { + background: rgba(var(--interactive-accent-rgb), 0.15); + outline: 2px dashed var(--interactive-accent); + outline-offset: -2px; + border-radius: 4px; +} + +/* ── Modal Styles ────────────────────────────────────────── */ + +.side-bookmark-modal { + padding: 8px 0; +} + +.side-bookmark-modal h3 { + margin: 0 0 16px 0; + font-size: 18px; + font-weight: 600; +} + +.side-bookmark-modal p { + color: var(--text-muted); + margin: 0 0 16px 0; + line-height: 1.5; +} + +/* ── Settings Stats ──────────────────────────────────────── */ + +.sb-settings-stats { + padding: 8px 0; + color: var(--text-muted); +} + +.sb-settings-stats p { + margin: 4px 0; + font-size: 13px; +} + +/* ── Responsive ──────────────────────────────────────────── */ + +/* When sidebar is narrow, adjust padding */ +@media (max-width: 300px) { + .sb-nav-bar { + padding: 4px 4px; + gap: 2px; + } + + .sb-url-input { + font-size: 11px; + padding: 0 6px; + } + + .sb-tree-item-row { + padding: 3px 4px; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..222535d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noImplicitReturns": true, + "moduleResolution": "node", + "importHelpers": true, + "noUncheckedIndexedAccess": true, + "isolatedModules": true, + "strictNullChecks": true, + "strictBindCallApply": true, + "allowSyntheticDefaultImports": true, + "useUnknownInCatchVariables": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/version-bump.mjs b/version-bump.mjs new file mode 100644 index 0000000..55d631f --- /dev/null +++ b/version-bump.mjs @@ -0,0 +1,17 @@ +import { readFileSync, writeFileSync } from "fs"; + +const targetVersion = process.env.npm_package_version; + +// read minAppVersion from manifest.json and bump version to target version +const manifest = JSON.parse(readFileSync("manifest.json", "utf8")); +const { minAppVersion } = manifest; +manifest.version = targetVersion; +writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); + +// update versions.json with target version and minAppVersion from manifest.json +// but only if the target version is not already in versions.json +const versions = JSON.parse(readFileSync('versions.json', 'utf8')); +if (!Object.values(versions).includes(minAppVersion)) { + versions[targetVersion] = minAppVersion; + writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..4fde155 --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.0.1": "0.15.0" +}