mirror of
https://github.com/bcs1037/side-bookmark.git
synced 2026-07-22 06:06:38 +00:00
feat: initial release plugin side-bookmark v0.0.1
This commit is contained in:
commit
b0842920eb
20 changed files with 2764 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
PRD/
|
||||
.DS_Store
|
||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal 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
41
README.md
Normal 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
49
esbuild.config.mjs
Normal 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
7
main.js
Normal file
File diff suppressed because one or more lines are too long
9
manifest.json
Normal file
9
manifest.json
Normal 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
614
package-lock.json
generated
Normal 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
23
package.json
Normal 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
417
src/BookmarkPanel.ts
Normal 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
242
src/BookmarkStore.ts
Normal 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
96
src/BookmarkView.ts
Normal 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
202
src/BrowserPanel.ts
Normal 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
71
src/main.ts
Normal 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
394
src/modals.ts
Normal 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
57
src/settings.ts
Normal 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
52
src/types.ts
Normal 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
425
styles.css
Normal 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
30
tsconfig.json
Normal 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
17
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"0.0.1": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue