feat: initial release plugin side-bookmark v0.0.1

This commit is contained in:
BCS 2026-04-02 09:21:08 +08:00
commit b0842920eb
20 changed files with 2764 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
PRD/
.DS_Store

12
CHANGELOG.md Normal file
View file

@ -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.

41
README.md Normal file
View file

@ -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**

49
esbuild.config.mjs Normal file
View file

@ -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();
}

7
main.js Normal file

File diff suppressed because one or more lines are too long

9
manifest.json Normal file
View file

@ -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
}

614
package-lock.json generated Normal file
View file

@ -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
}
}
}

23
package.json Normal file
View file

@ -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"
}
}

417
src/BookmarkPanel.ts Normal file
View file

@ -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
}
});
}
}

242
src/BookmarkStore.ts Normal file
View file

@ -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<void> {
const saved = await this.plugin.loadData() as Partial<SideBookmarkData> | null;
if (saved) {
this.data = {
...DEFAULT_DATA,
...saved,
};
}
}
/** Save data to Obsidian's persistent storage */
async save(): Promise<void> {
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<BookmarkItem> {
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<void> {
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<Omit<BookmarkItem, 'id' | 'type'>>): Promise<void> {
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<void> {
await this.updateBookmark(id, { folderId: targetFolderId });
}
// ── Folder CRUD ──────────────────────────────────────────
/** Add a new folder */
async addFolder(name: string, parentId: string | null = null): Promise<BookmarkFolder> {
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<void> {
// 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<Omit<BookmarkFolder, 'id' | 'type'>>): Promise<void> {
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<void> {
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;
}
}

96
src/BookmarkView.ts Normal file
View file

@ -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<void> {
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<void> {
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);
}
}
}

202
src/BrowserPanel.ts Normal file
View file

@ -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;
}
}

71
src/main.ts Normal file
View file

@ -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<void> {
// 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<void> {
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);
}
}
}
}

394
src/modals.ts Normal file
View file

@ -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<void>;
constructor(app: App, message: string, onConfirm: () => Promise<void>) {
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();
}
}

57
src/settings.ts Normal file
View file

@ -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}`,
});
}
}

52
src/types.ts Normal file
View file

@ -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);
}

425
styles.css Normal file
View file

@ -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;
}
}

30
tsconfig.json Normal file
View file

@ -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"
]
}

17
version-bump.mjs Normal file
View file

@ -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'));
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"0.0.1": "0.15.0"
}