This commit is contained in:
WesternGua 2026-05-03 14:23:37 +08:00
parent 8b7d1cb58d
commit d02cfe32ee
19 changed files with 609 additions and 14413 deletions

View file

@ -6,6 +6,22 @@
---
## [0.2.1] - 2026-05-03
### 改进
- 对齐 Obsidian 官方插件规范命令名、README、发布说明与实际行为同步不再依赖手工命令前缀
- 将静态内联样式迁移到 `styles.css`,并把状态颜色改为 Obsidian 主题变量
- 构建产物改为生产模式压缩GitHub Release 说明同步要求附带 `styles.css`
### 修复
- 扩充 README 披露说明,补充本地回环通信、临时文件、`pandoc` / `sqlite3` / `osascript` 等外部访问说明
- 改用 Obsidian `Platform` / `FileSystemAdapter` API减少与官方规范不符的运行时用法
- 仓库不再跟踪 `main.js`,源码仓库与本地运行目录职责更清晰
---
## [0.2.0] - 2026-05-03
### 新增

View file

@ -34,6 +34,7 @@
1. 从 GitHub Releases 页面下载以下文件:
- `main.js` — 插件主程序
- `manifest.json` — 插件清单
- `styles.css` — 插件样式
2. 将以上文件放入 vault 的 `.obsidian/plugins/zotero-citations/` 目录(如目录不存在则手动创建)
3. 在 Obsidian 设置 → 第三方插件中启用 **Zotero Citations**
4. 确保 Zotero 已打开且 Better BibTeX 已安装
@ -47,11 +48,21 @@
---
## 披露说明
- **网络访问**:插件会通过 `127.0.0.1` 本地回环地址与 Zotero / Better BibTeX 通信,不会连接插件自建服务器。
- **外部文件与可执行程序访问**:插件会读取本机 Zotero 样式目录;在部分回退场景下可能复制并读取本机 Zotero 数据库到系统临时目录;导出 Word 时会调用本地 `pandoc`;数据库回退解析时可能调用本地 `sqlite3`;在 macOS 上,为了从 Zotero 选择器返回 Obsidian插件可能调用系统 `osascript`
- **本地数据存储**:插件会在 Obsidian 的插件数据中保存设置项和引用缓存。
- **账户 / 付费 / 广告 / 遥测**:插件不要求登录账户,不含广告,不含内购,也不主动收集遥测数据。
- **源码状态**:插件源码已公开在 GitHub 仓库中,并以 MIT 许可证发布:<https://github.com/WesternGua/obsidian-zotero-citations>
---
## 快速上手
### 1. 插入引用
在命令面板中执行 `Zotero Citations: 插入引用`,或点击标题栏中的插入引用图标。
在命令面板中搜索“插入引用”,或点击标题栏中的插入引用图标。
插件会优先调用 Zotero 原生引文选择器——在弹出窗口中搜索并选择条目,可填写页码等定位符,点击右上角 ✔ 确认插入。
@ -59,7 +70,7 @@
> **注意**
> - 插件会在脚注正文开头写入 `<!-- zotero:ITEMKEY:locator -->` 形式的隐藏元数据,请勿手动删除,否则插件无法识别该引用。
> - 定稿后如需移除插件元数据,可执行 `Zotero Citations: 解除引用链接`(不可逆),插件会删除隐藏元数据并保留可见引用文本。
> - 定稿后如需移除插件元数据,可执行“解除引用链接”(不可逆),插件会删除隐藏元数据并保留可见引用文本。
![insert-citation-preview](assets/screenshots/insert-citation-preview.png)
@ -71,20 +82,20 @@
### 3. 切换引用样式
执行 `Zotero Citations: 文档首选项` 打开文档首选项窗口。插件会动态读取 Zotero 中已安装的所有 CSL 样式,以可搜索列表形式展示;选择所需样式后,可同时切换脚注/尾注模式,并一键应用到当前文档的所有引用:
执行“文档首选项”打开文档首选项窗口。插件会动态读取 Zotero 中已安装的所有 CSL 样式,以可搜索列表形式展示;选择所需样式后,可同时切换脚注/尾注模式,并一键应用到当前文档的所有引用:
![文档首选项](assets/screenshots/zh-preferences.png)
### 4. 插入参考书目
执行 `Zotero Citations: 插入参考书目`,插件会在光标位置生成当前文档所有引用的参考文献列表。参考书目在导出 Word 时也会保留。
执行“插入参考书目”,插件会在光标位置生成当前文档所有引用的参考文献列表。参考书目在导出 Word 时也会保留。
![insert-bibliography-preview](assets/screenshots/insert-bibliography-preview.png)
### 5. 导出为 Word
1. 在命令面板中执行 `Zotero Citations: 检测 Pandoc 是否可用`,确认 Pandoc 正常工作
2. 执行 `Zotero Citations: 导出为 Word (.docx)`
1. 在命令面板中执行“检测 Pandoc 是否可用”,确认 Pandoc 正常工作
2. 执行“导出为 Word (.docx)”
导出的 Word 文档包含正确格式的脚注正文为宋体小四、1.5 倍行距、两端对齐、首行缩进两字符,标题为黑体。
@ -118,7 +129,7 @@
## 命令列表
所有命令均以 `Zotero Citations:` 为前缀,方便在命令面板中搜索:
以下为插件提供的命令;在命令面板中可直接按命令名称搜索:
| 命令 | 说明 |
|------|------|

View file

@ -32,6 +32,7 @@
1. Download the following files from the GitHub Releases page:
- `main.js` — plugin runtime
- `manifest.json` — plugin manifest
- `styles.css` — plugin styles
2. Place the files into your vault's `.obsidian/plugins/zotero-citations/` directory (create it if it does not exist)
3. Enable **Zotero Citations** in Obsidian Settings → Community plugins
4. Make sure Zotero is running and Better BibTeX is installed
@ -45,11 +46,21 @@ This version has been developed and tested primarily on **macOS**. Linux and Win
---
## Disclosures
- **Network use**: the plugin talks to Zotero / Better BibTeX over the local loopback address `127.0.0.1`; it does not rely on a plugin-operated remote server.
- **External files and executables**: the plugin reads the local Zotero styles directory; in fallback scenarios it may copy and read the local Zotero database in the system temp directory; for Word export it invokes the local `pandoc` executable; for database fallback parsing it may invoke the local `sqlite3` executable; on macOS it may invoke the system `osascript` command to return focus to Obsidian after the Zotero picker closes.
- **Local data storage**: the plugin stores settings and citation cache data in Obsidian's plugin data storage.
- **Accounts / payments / ads / telemetry**: the plugin does not require an account, does not include ads, does not include in-app payments, and does not intentionally collect telemetry.
- **Source availability**: the plugin source code is published on GitHub under the MIT license: <https://github.com/WesternGua/obsidian-zotero-citations>
---
## Quick Start
### 1. Insert a Citation
Run `Zotero Citations: Insert citation` from the command palette, or click the citation icon in the title bar.
Search for `Insert citation` in the command palette, or click the citation icon in the title bar.
The plugin will first try to open Zotero's native citation picker — search for items, add a page number or other locator, and confirm with the checkmark button.
@ -57,7 +68,7 @@ Inserted citations appear as footnotes or endnotes depending on your current cit
> **Note**:
> - The plugin writes hidden metadata (`<!-- zotero:ITEMKEY:locator -->`) at the beginning of each note. Do not remove it manually, or the plugin will not be able to track the citation.
> - When you are ready to finalize, run `Zotero Citations: Unlink citations` (irreversible) to strip the hidden metadata while keeping the visible citation text.
> - When you are ready to finalize, run `Unlink citations` (irreversible) to strip the hidden metadata while keeping the visible citation text.
![insert-citation-preview](assets/screenshots/insert-citation-preview.png)
@ -69,20 +80,20 @@ With Word-style footnote display enabled, hover over a superscript number to pre
### 3. Switch Citation Style
Run `Zotero Citations: Document preferences` to open the preferences panel. The plugin dynamically reads all CSL styles installed in your Zotero and presents them in a searchable list. Pick a style, optionally switch between footnote/endnote mode, and apply the change to all citations in the current document at once:
Run `Document preferences` to open the preferences panel. The plugin dynamically reads all CSL styles installed in your Zotero and presents them in a searchable list. Pick a style, optionally switch between footnote/endnote mode, and apply the change to all citations in the current document at once:
![en-preferences](assets/screenshots/en-preferences.png)
### 4. Insert a Bibliography
Run `Zotero Citations: Insert bibliography` to generate a formatted reference list at the cursor position. The bibliography is also preserved when exporting to Word.
Run `Insert bibliography` to generate a formatted reference list at the cursor position. The bibliography is also preserved when exporting to Word.
![en-insert-bibliography](assets/screenshots/en-insert-bibliography.png)
### 5. Export to Word
1. Run `Zotero Citations: Check whether Pandoc is available` first to confirm Pandoc is working.
2. Run `Zotero Citations: Export to Word (.docx)`.
1. Run `Check whether Pandoc is available` first to confirm Pandoc is working.
2. Run `Export to Word (.docx)`.
The exported Word document contains properly formatted footnotes, with body text in SimSun 12pt, 1.5 line spacing, justified alignment, first-line indent, and headings in SimHei.
@ -114,7 +125,7 @@ The exported Word document contains properly formatted footnotes, with body text
## Command List
All commands are prefixed with `Zotero Citations:` for easy discovery in the command palette:
The plugin provides the following commands; you can search for them directly by name in the command palette:
| Command | Description |
|---------|-------------|

View file

@ -1,21 +1,21 @@
# Zotero Citations v0.2.0
# Zotero Citations v0.2.1
## 新功能
## 官方规范对齐 / Compliance
- **动态 CSL 样式读取** — 文档首选项窗口自动扫描 Zotero 已安装的所有 CSL 样式,支持搜索过滤和刷新
- **标题栏按钮独立控制** — 6 个快捷图标可在设置中单独开关,按需定制工作区
- **参考书目格式优化** — 自动添加一级标题 + 引用块,导出 Word 时无缩进、两端对齐
- **命令与文档同步** — 命令面板名称、README 与发布说明不再保留错误的手工前缀说法
- **样式合规化** — 静态内联样式迁移到 `styles.css`,状态色改为 Obsidian 主题变量
- **构建与发布同步** — 生产构建改为压缩输出Release 资产说明补充 `styles.css`
## 改进
## 披露与仓库整理
- 命令面板统一 `Zotero Citations:` 前缀,不再与其他插件命令混淆
- 文档首选项窗口完全重写,样式列表可搜索、可刷新
- Modal 对话框标题对齐优化
- Word 导出模板样式调整(参考书目段落取消缩进)
- **README 披露补全** — 补充回环网络、临时文件、本地 `pandoc` / `sqlite3` / `osascript` 调用说明
- **仓库结构更标准**`main.js` 继续作为本地运行产物存在,但不再纳入 Git 跟踪
- **开发文档同步**`docs/DEVELOPMENT.md` 与当前目录结构、Release 流程保持一致
## 修复
## 代码修复
- `toolbarButtons` 设置首次加载兼容性修复
- 改用 Obsidian `Platform` / `FileSystemAdapter` API减少与官方规范不符的实现
- 清理剩余静态内联样式,设置页与各模态窗口样式统一交给 `styles.css`
---

View file

@ -6,7 +6,6 @@ This plugin directory now follows a more standard Obsidian plugin source-repo la
```text
zotero-citations/
├── .github/workflows/ # GitHub Actions
├── assets/screenshots/ # README images
├── docs/ # Project docs
├── src/ # TypeScript source
@ -14,14 +13,16 @@ zotero-citations/
├── LICENSE
├── README.md
├── README_EN.md
├── RELEASE_NOTES.md
├── esbuild.config.mjs
├── manifest.json
├── package.json
├── package-lock.json
├── styles.css # Runtime stylesheet loaded by Obsidian
├── tsconfig.json
├── version-bump.mjs
├── versions.json
└── main.js # Build output used by Obsidian at runtime
└── main.js # Generated build output used locally by Obsidian (kept untracked in Git)
```
## Local Workflow
@ -45,9 +46,9 @@ This folder is both:
1. a source repository layout, and
2. the live plugin directory under `.obsidian/plugins/`
So `main.js` must continue to exist locally for Obsidian to load the plugin, even though `.gitignore` excludes it for a cleaner source-repo workflow.
So `main.js` must continue to exist locally for Obsidian to load the plugin, even though `.gitignore` excludes it from the GitHub source repository.
Likewise, `data.json` is local runtime state and should stay untracked.
Likewise, `data.json` is local runtime state and should stay untracked. In contrast, `styles.css` is a real plugin asset and should stay versioned.
## Release Metadata
@ -62,4 +63,4 @@ Likewise, `data.json` is local runtime state and should stay untracked.
3. Update `CHANGELOG.md`
4. Bump the version with `npm version ...`
5. Confirm `manifest.json` and `versions.json` were updated
6. Attach `main.js` and `manifest.json` to the GitHub release if distributing release artifacts
6. Attach `main.js`, `manifest.json`, and `styles.css` to the GitHub release if distributing release artifacts

View file

@ -17,6 +17,7 @@ esbuild.build({
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
minify: prod,
treeShaking: true,
outfile: "main.js",
platform: "node",

13980
main.js

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"id": "zotero-citations",
"name": "Zotero Citations",
"version": "0.2.0",
"version": "0.2.1",
"minAppVersion": "1.5.7",
"description": "Zotero citation management with Word export via Pandoc.",
"author": "WesternGua",

View file

@ -1,6 +1,6 @@
{
"name": "zotero-citations",
"version": "0.2.0",
"version": "0.2.1",
"description": "Zotero citation management with Word export via Pandoc.",
"main": "main.js",
"scripts": {

View file

@ -1,7 +1,7 @@
/**
* ExportManager.ts Pandoc export and reference document generation
*/
import { Notice } from "obsidian";
import { Notice, Platform } from "obsidian";
import { t } from "./i18n";
import { ZoteroCitationsSettings, DEFAULT_SETTINGS } from "./settings";
@ -93,7 +93,7 @@ export class ExportManager {
static q(s: string): string {
if (!s) return "";
return process.platform === "win32"
return Platform.isWin
? `"${s.replace(/"/g, '\\"')}"`
: `'${s.replace(/'/g, "'\\''")}'`;
}

View file

@ -2,7 +2,7 @@
* ZoteroAPI.ts Communication with Zotero via Better BibTeX HTTP API
* Improvement 2: Added getInstalledStyles() to dynamically read CSL styles from Zotero
*/
import { requestUrl } from "obsidian";
import { Platform, requestUrl } from "obsidian";
import * as nodeHttp from "http";
import * as fs from "fs";
import * as os from "os";
@ -102,7 +102,7 @@ export class ZoteroAPI {
async ping(): Promise<boolean> {
try {
const r = await requestUrl({ url: `${this.baseUrl}/connector/ping`, method: "GET", throw: false } as any);
const r = await requestUrl({ url: `${this.baseUrl}/connector/ping`, method: "GET", throw: false });
return r.status === 200;
} catch (e) {
return false;
@ -120,10 +120,10 @@ export class ZoteroAPI {
const home = os.homedir();
const candidates: string[] = [];
if (process.platform === "darwin") {
if (Platform.isMacOS) {
candidates.push(path.join(home, "Zotero", "styles"));
candidates.push(path.join(home, "Library", "Application Support", "Zotero", "Profiles"));
} else if (process.platform === "win32") {
} else if (Platform.isWin) {
const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
candidates.push(path.join(appdata, "Zotero", "Zotero", "Profiles"));
candidates.push(path.join(home, "Zotero", "styles"));
@ -260,7 +260,7 @@ export class ZoteroAPI {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "item.search", params: [query], id: 1 }),
throw: false,
} as any);
});
if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
const d = r.json;
if (d.error) throw new Error(d.error.message);
@ -280,7 +280,7 @@ export class ZoteroAPI {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "item.citationkey", params: [itemKeys], id: 2 }),
throw: false,
} as any);
});
if (r.status !== 200) return map;
const d = r.json;
if (d.error || !d.result || typeof d.result !== "object") return map;
@ -308,7 +308,7 @@ export class ZoteroAPI {
id: 3,
}),
throw: false,
} as any);
});
if (r.status === 200) {
const d = r.json;
if (!d?.error && d?.result) {
@ -346,7 +346,7 @@ export class ZoteroAPI {
id: 4,
}),
throw: false,
} as any);
});
const d = r.json;
if (r.status === 200 && !d.error && d.result) {
const items = JSON.parse(d.result);

View file

@ -1,14 +1,15 @@
/**
* FootnoteExtension.ts CodeMirror extension for Word-style footnote rendering
*/
import { Component, MarkdownRenderer, MarkdownView, Notice } from "obsidian";
import { App, Component, MarkdownRenderer, MarkdownView, Notice } from "obsidian";
import { ViewPlugin, WidgetType, Decoration, EditorView, ViewUpdate } from "@codemirror/view";
import { appT } from "../i18n";
import { CitationManager } from "../CitationManager";
import { ZoteroItem } from "../ZoteroAPI";
export interface FootnoteExtensionOptions {
isEnabled: () => boolean;
app: any;
app: App;
getSourcePath: () => string;
}
@ -18,22 +19,43 @@ interface PreviewInfo {
edit?: EditInfo | null;
}
interface EditInfo {
interface PopoverSpec {
app: App;
getSourcePath: () => string;
markdown: string;
fallbackText: string;
edit?: EditInfo;
}
type PopoverAttacher = (target: HTMLElement, spec: PopoverSpec) => void;
interface InlineEditInfo {
kind: "inline" | "endnote";
key: string;
locator: string;
label?: string;
from: number;
to: number;
}
interface EndnoteEditInfo {
kind: "endnote";
key: string;
locator: string;
label: string;
from: number;
to: number;
}
type EditInfo = Omit<InlineEditInfo, "kind"> & { kind: "inline" } | EndnoteEditInfo;
// ── Widget ─────────────────────────────────────────────────────────────────
class FnWidget extends WidgetType {
constructor(
public num: number,
public preview: PreviewInfo,
public app: any,
public app: App,
public getSourcePath: () => string,
public attachPopover: PopoverAttacher,
public identifier: string,
public domId: string,
public isHighlighted: boolean = false,
@ -75,7 +97,7 @@ class FnWidget extends WidgetType {
marker.addEventListener("mousedown", (event) => event.preventDefault());
marker.addEventListener("click", (event) => event.preventDefault());
attachRenderedPopover(marker, {
this.attachPopover(marker, {
app: this.app,
getSourcePath: this.getSourcePath,
markdown: this.preview.markdown,
@ -103,15 +125,16 @@ class FnWidget extends WidgetType {
// ── Extension factory ──────────────────────────────────────────────────────
export function createFootnoteExtension(options: FootnoteExtensionOptions) {
const attachPopover = createPopoverAttacher();
return ViewPlugin.fromClass(
class {
decorations: any;
constructor(view: EditorView) {
this.decorations = buildDeco(view, options);
this.decorations = buildDeco(view, options, attachPopover);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = buildDeco(update.view, options);
this.decorations = buildDeco(update.view, options, attachPopover);
}
}
},
@ -119,7 +142,7 @@ export function createFootnoteExtension(options: FootnoteExtensionOptions) {
);
}
function buildDeco(view: EditorView, options: FootnoteExtensionOptions) {
function buildDeco(view: EditorView, options: FootnoteExtensionOptions, attachPopover: PopoverAttacher) {
if (!options.isEnabled()) return Decoration.none;
const doc = view.state.doc.toString();
@ -194,7 +217,7 @@ function buildDeco(view: EditorView, options: FootnoteExtensionOptions) {
Decoration.replace({
widget: new FnWidget(
num, { markdown, text, edit }, options.app,
options.getSourcePath, identifier, domId,
options.getSourcePath, attachPopover, identifier, domId,
isInsideHighlight(doc, start, end),
),
}).range(start, end),
@ -232,7 +255,7 @@ function countHighlightDelimiters(text: string): number {
return count;
}
function extractInlinePreview(rawMarker: string, num: number, app: any): { markdown: string; text: string; edit?: EditInfo | null } {
function extractInlinePreview(rawMarker: string, num: number, app: App): { markdown: string; text: string; edit?: EditInfo | null } {
const body = rawMarker.slice(2, -1);
const metadata = parseZoteroMetadata(body);
const markdown = metadata.markdown.trim();
@ -244,7 +267,7 @@ function extractInlinePreview(rawMarker: string, num: number, app: any): { markd
};
}
function buildEndnotePreviewMap(doc: string, app: any): Map<string, { markdown: string; text: string; edit?: EditInfo | null }> {
function buildEndnotePreviewMap(doc: string, app: App): Map<string, { markdown: string; text: string; edit?: EditInfo | null }> {
const map = new Map<string, { markdown: string; text: string; edit?: EditInfo | null }>();
const lines = doc.split("\n");
const lineOffsets: number[] = [];
@ -301,80 +324,125 @@ function normalizeTooltipText(text: string): string {
}
// ── Popover system ─────────────────────────────────────────────────────────
let activePopover: {
interface ActivePopover {
target: HTMLElement; popover: HTMLElement; component: Component;
hideTimer: number | null; reposition: () => void;
onPopoverEnter: () => void; onPopoverLeave: () => void;
} | null = null;
interface PopoverSpec {
app: any;
getSourcePath: () => string;
markdown: string;
fallbackText: string;
edit?: EditInfo;
}
function attachRenderedPopover(target: HTMLElement, spec: PopoverSpec): void {
const show = () => showRenderedPopover(target, spec);
const scheduleHide = () => schedulePopoverHide(target);
target.addEventListener("mouseenter", show);
target.addEventListener("mouseleave", scheduleHide);
target.addEventListener("mousemove", show);
target.addEventListener("pointerenter", show);
target.addEventListener("pointerleave", scheduleHide);
target.addEventListener("pointermove", show);
target.addEventListener("focus", show);
target.addEventListener("blur", scheduleHide);
interface ZoteroPluginLike {
settings: { cslStyle: string };
getCached: (key: string) => ZoteroItem | undefined;
fetchAndCache: (key: string) => Promise<ZoteroItem | null>;
}
function showRenderedPopover(target: HTMLElement, spec: PopoverSpec): void {
if (!spec.markdown && !spec.fallbackText) return;
if (activePopover?.target === target) {
type AppWithPlugins = App & {
plugins?: {
plugins?: Record<string, ZoteroPluginLike | undefined>;
};
};
function createPopoverAttacher(): PopoverAttacher {
let activePopover: ActivePopover | null = null;
const cancelPopoverHide = (): void => {
if (!activePopover?.hideTimer) return;
window.clearTimeout(activePopover.hideTimer);
activePopover.hideTimer = null;
};
const destroyActivePopover = (): void => {
if (!activePopover) return;
const { popover, component, reposition, onPopoverEnter, onPopoverLeave, hideTimer } = activePopover;
if (hideTimer) window.clearTimeout(hideTimer);
popover.removeEventListener("mouseenter", onPopoverEnter);
popover.removeEventListener("mouseleave", onPopoverLeave);
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
component.unload();
popover.remove();
activePopover = null;
};
const schedulePopoverHide = (target: HTMLElement): void => {
if (!activePopover || activePopover.target !== target) return;
cancelPopoverHide();
activePopover.reposition();
return;
}
destroyActivePopover();
activePopover.hideTimer = window.setTimeout(() => {
if (activePopover?.target === target) destroyActivePopover();
}, 80);
};
const popover = document.createElement("div");
popover.className = "popover hover-popover zotero-footnote-popover";
popover.style.position = "fixed";
const showRenderedPopover = (target: HTMLElement, spec: PopoverSpec): void => {
if (!spec.markdown && !spec.fallbackText) return;
if (activePopover?.target === target) {
cancelPopoverHide();
activePopover.reposition();
return;
}
destroyActivePopover();
const embed = popover.createDiv({ cls: "markdown-embed", attr: { "data-type": "footnote" } });
const embedContent = embed.createDiv({ cls: "markdown-embed-content" });
const preview = embedContent.createDiv({ cls: "markdown-preview-view markdown-rendered" });
preview.setText(spec.fallbackText);
const popover = document.createElement("div");
popover.className = "popover hover-popover zotero-footnote-popover";
if (spec.edit) {
mountLocatorEditor(embedContent, spec, target);
}
if (!spec.markdown.trim()) embed.addClass("mod-empty");
const embed = popover.createDiv({ cls: "markdown-embed", attr: { "data-type": "footnote" } });
const embedContent = embed.createDiv({ cls: "markdown-embed-content" });
const preview = embedContent.createDiv({ cls: "markdown-preview-view markdown-rendered" });
preview.setText(spec.fallbackText);
document.body.appendChild(popover);
const component = new Component();
const reposition = () => positionPopover(target, popover);
const onPopoverEnter = () => cancelPopoverHide();
const onPopoverLeave = () => schedulePopoverHide(target);
popover.addEventListener("mouseenter", onPopoverEnter);
popover.addEventListener("mouseleave", onPopoverLeave);
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
if (spec.edit) {
mountLocatorEditor(
embedContent,
spec,
() => activePopover?.target === target,
destroyActivePopover,
);
}
if (!spec.markdown.trim()) embed.addClass("mod-empty");
activePopover = { target, popover, component, hideTimer: null, reposition, onPopoverEnter, onPopoverLeave };
reposition();
requestAnimationFrame(reposition);
document.body.appendChild(popover);
const component = new Component();
const reposition = () => positionPopover(target, popover);
const onPopoverEnter = () => cancelPopoverHide();
const onPopoverLeave = () => schedulePopoverHide(target);
popover.addEventListener("mouseenter", onPopoverEnter);
popover.addEventListener("mouseleave", onPopoverLeave);
window.addEventListener("scroll", reposition, true);
window.addEventListener("resize", reposition);
if (spec.markdown.trim()) {
void renderPopoverMarkdown(preview, spec, component, target, reposition);
}
activePopover = { target, popover, component, hideTimer: null, reposition, onPopoverEnter, onPopoverLeave };
reposition();
requestAnimationFrame(reposition);
if (spec.markdown.trim()) {
void renderPopoverMarkdown(preview, spec, component, reposition, () => activePopover?.target === target);
}
};
return (target: HTMLElement, spec: PopoverSpec): void => {
const show = () => showRenderedPopover(target, spec);
const scheduleHide = () => schedulePopoverHide(target);
target.addEventListener("mouseenter", show);
target.addEventListener("mouseleave", scheduleHide);
target.addEventListener("mousemove", show);
target.addEventListener("pointerenter", show);
target.addEventListener("pointerleave", scheduleHide);
target.addEventListener("pointermove", show);
target.addEventListener("focus", show);
target.addEventListener("blur", scheduleHide);
};
}
async function renderPopoverMarkdown(preview: HTMLElement, spec: PopoverSpec, component: Component, target: HTMLElement, reposition: () => void): Promise<void> {
async function renderPopoverMarkdown(
preview: HTMLElement,
spec: PopoverSpec,
component: Component,
reposition: () => void,
isActiveTarget: () => boolean,
): Promise<void> {
try {
preview.empty();
await MarkdownRenderer.render(spec.app, spec.markdown, preview, spec.getSourcePath(), component);
if (activePopover?.target !== target) return;
if (!isActiveTarget()) return;
reposition();
requestAnimationFrame(reposition);
} catch (e) {
@ -382,7 +450,12 @@ async function renderPopoverMarkdown(preview: HTMLElement, spec: PopoverSpec, co
}
}
function mountLocatorEditor(container: HTMLElement, spec: PopoverSpec, target: HTMLElement): void {
function mountLocatorEditor(
container: HTMLElement,
spec: PopoverSpec,
isActiveTarget: () => boolean,
destroyPopover: () => void,
): void {
const wrap = container.createDiv({ cls: "zotero-footnote-locator-editor" });
const input = wrap.createEl("input", { type: "text" });
input.value = spec.edit!.locator || "";
@ -394,33 +467,38 @@ function mountLocatorEditor(container: HTMLElement, spec: PopoverSpec, target: H
saveBtn.disabled = true;
const ok = await applyLocatorEdit(spec, input.value.trim());
saveBtn.disabled = false;
if (ok && activePopover?.target === target) destroyActivePopover();
if (ok && isActiveTarget()) destroyPopover();
};
saveBtn.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); void save(); });
input.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void save(); } });
}
async function applyLocatorEdit(spec: PopoverSpec, locator: string): Promise<boolean> {
const plugin = spec.app.plugins.plugins["zotero-citations"];
const edit = spec.edit;
if (!edit) {
new Notice(appT(spec.app, "footnote.noEditor"));
return false;
}
const plugin = (spec.app as AppWithPlugins).plugins?.plugins?.["zotero-citations"];
const view = spec.app.workspace.getActiveViewOfType(MarkdownView);
const editor = view?.editor;
if (!plugin || !editor) {
new Notice(appT(spec.app, "footnote.noEditor"));
return false;
}
const item = plugin.getCached(spec.edit!.key) || await plugin.fetchAndCache(spec.edit!.key);
const item = plugin.getCached(edit.key) || await plugin.fetchAndCache(edit.key);
if (!item) {
new Notice(appT(spec.app, "footnote.noItem"));
return false;
}
const page = locator || undefined;
const style = plugin.settings.cslStyle;
if (spec.edit!.kind === "inline") {
if (edit.kind === "inline") {
const replacement = CitationManager.buildInlineFootnote(item, style, page);
editor.replaceRange(replacement, editor.offsetToPos(spec.edit!.from), editor.offsetToPos(spec.edit!.to));
editor.replaceRange(replacement, editor.offsetToPos(edit.from), editor.offsetToPos(edit.to));
} else {
const replacement = CitationManager.buildEndnoteDef((spec.edit as any).label, item, style, page);
editor.replaceRange(replacement, editor.offsetToPos(spec.edit!.from), editor.offsetToPos(spec.edit!.to));
const replacement = CitationManager.buildEndnoteDef(edit.label, item, style, page);
editor.replaceRange(replacement, editor.offsetToPos(edit.from), editor.offsetToPos(edit.to));
}
new Notice(appT(spec.app, "footnote.updated"));
return true;
@ -440,30 +518,3 @@ function positionPopover(target: HTMLElement, popover: HTMLElement): void {
popover.style.top = `${top}px`;
popover.style.left = `${left}px`;
}
function schedulePopoverHide(target: HTMLElement): void {
if (!activePopover || activePopover.target !== target) return;
cancelPopoverHide();
activePopover.hideTimer = window.setTimeout(() => {
if (activePopover?.target === target) destroyActivePopover();
}, 80);
}
function cancelPopoverHide(): void {
if (!activePopover?.hideTimer) return;
window.clearTimeout(activePopover.hideTimer);
activePopover.hideTimer = null;
}
function destroyActivePopover(): void {
if (!activePopover) return;
const { popover, component, reposition, onPopoverEnter, onPopoverLeave, hideTimer } = activePopover;
if (hideTimer) window.clearTimeout(hideTimer);
popover.removeEventListener("mouseenter", onPopoverEnter);
popover.removeEventListener("mouseleave", onPopoverLeave);
window.removeEventListener("scroll", reposition, true);
window.removeEventListener("resize", reposition);
component.unload();
popover.remove();
activePopover = null;
}

View file

@ -27,6 +27,13 @@ type SelectionSnapshot = {
to: any;
};
type AppWithCommands = obsidian.App & {
commands?: {
commands?: Record<string, { name: string }>;
executeCommandById?: (commandId: string) => void;
};
};
const ZOTERO_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="10" y="10" width="80" height="80" rx="10" fill="currentColor" opacity="0.12"/>
<text x="50" y="70" font-size="58" text-anchor="middle" font-family="serif" fill="currentColor" font-weight="bold">Z</text>
@ -86,7 +93,6 @@ export default class ZoteroCitations extends obsidian.Plugin {
editorExtension: any = null;
ribbonIconEl: HTMLElement | null = null;
titlebarActions: WeakMap<any, HTMLElement[]> = new WeakMap();
styleEl: HTMLStyleElement | null = null;
focusBurstTimer: number | null = null;
focusBurstStopTimer: number | null = null;
focusBurstTopmostResetTimer: number | null = null;
@ -161,13 +167,11 @@ export default class ZoteroCitations extends obsidian.Plugin {
});
this.addSettingTab(new ZoteroSettingTab(this.app, this));
this.injectStyles();
this.applyLanguage();
this.registerEvent(this.app.workspace.on("active-leaf-change", () => this.refreshToolbars()));
this.registerEvent(this.app.workspace.on("layout-change", () => this.refreshToolbars()));
window.setTimeout(() => this.refreshToolbars(), 100);
this.app.workspace.onLayoutReady(() => this.refreshToolbars());
}
onunload() {
@ -184,9 +188,6 @@ export default class ZoteroCitations extends obsidian.Plugin {
this.clearToolbar(leaf.view);
}
});
this.styleEl?.remove();
this.styleEl = null;
}
// ══ Settings ══════════════════════════════════════════════════════════════
@ -213,23 +214,22 @@ export default class ZoteroCitations extends obsidian.Plugin {
}
getCommandLabels() {
const prefix = "Zotero Citations: ";
return {
"insert-edit-citation": prefix + this.t("command.insertCitation"),
"toggle-word-display": prefix + this.t("command.toggleWordDisplay"),
"toggle-toolbar": prefix + this.t("command.toggleToolbar"),
"insert-bibliography": prefix + this.t("command.insertBibliography"),
"refresh-citations": prefix + this.t("command.refreshCitations"),
"export-to-word": prefix + this.t("command.exportToWord"),
"unlink-citations": prefix + this.t("command.unlinkCitations"),
"document-preferences": prefix + this.t("command.documentPreferences"),
"check-pandoc": prefix + this.t("command.checkPandoc"),
"insert-edit-citation": this.t("command.insertCitation"),
"toggle-word-display": this.t("command.toggleWordDisplay"),
"toggle-toolbar": this.t("command.toggleToolbar"),
"insert-bibliography": this.t("command.insertBibliography"),
"refresh-citations": this.t("command.refreshCitations"),
"export-to-word": this.t("command.exportToWord"),
"unlink-citations": this.t("command.unlinkCitations"),
"document-preferences": this.t("command.documentPreferences"),
"check-pandoc": this.t("command.checkPandoc"),
};
}
syncCommandLabels() {
const labels = this.getCommandLabels();
const commands = (this.app as any).commands?.commands ?? {};
const commands = (this.app as AppWithCommands).commands?.commands ?? {};
for (const [id, name] of Object.entries(labels)) {
const fullId = `${this.manifest.id}:${id}`;
@ -253,7 +253,7 @@ export default class ZoteroCitations extends obsidian.Plugin {
/** Force Markdown views to re-render so Word-style footnote settings update everywhere. */
refreshEditorExtension() {
(this.app.workspace as any).updateOptions();
this.app.workspace.updateOptions();
this.refreshMarkdownPreviews();
this.refreshToolbars();
}
@ -261,7 +261,7 @@ export default class ZoteroCitations extends obsidian.Plugin {
refreshMarkdownPreviews() {
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof obsidian.MarkdownView) {
(leaf.view as any).previewMode?.rerender(true);
leaf.view.previewMode?.rerender(true);
}
});
}
@ -299,7 +299,9 @@ export default class ZoteroCitations extends obsidian.Plugin {
const file = this.app.workspace.getActiveFile();
if (!file) return null;
const base = (this.app.vault.adapter as any).getBasePath?.();
const base = this.app.vault.adapter instanceof obsidian.FileSystemAdapter
? this.app.vault.adapter.getBasePath()
: null;
return base ? `${base}/${file.path}` : file.path;
}
@ -380,7 +382,7 @@ export default class ZoteroCitations extends obsidian.Plugin {
}
}
try {
if (process.platform === "darwin") {
if (obsidian.Platform.isMacOS) {
const { execFile } = require("child_process");
execFile(
"osascript",
@ -418,7 +420,7 @@ export default class ZoteroCitations extends obsidian.Plugin {
// noop
}
try {
(this.app as any).commands.executeCommandById("editor:focus");
(this.app as AppWithCommands).commands?.executeCommandById?.("editor:focus");
} catch (_e) {
// noop
}
@ -809,7 +811,7 @@ export default class ZoteroCitations extends obsidian.Plugin {
refreshToolbars() {
this.app.workspace.iterateAllLeaves((leaf) => {
if (leaf.view instanceof obsidian.MarkdownView) {
this.syncToolbar(leaf.view as any);
this.syncToolbar(leaf.view);
}
});
}
@ -821,7 +823,7 @@ export default class ZoteroCitations extends obsidian.Plugin {
new obsidian.Notice(this.settings.showToolbar ? this.t("notice.toolbarShown") : this.t("notice.toolbarHidden"));
}
syncToolbar(view: any) {
syncToolbar(view: obsidian.MarkdownView) {
this.clearToolbar(view);
if (!this.settings.showToolbar) return;
@ -864,7 +866,7 @@ export default class ZoteroCitations extends obsidian.Plugin {
this.titlebarActions.set(view, actionEls);
}
clearToolbar(view: any) {
clearToolbar(view: obsidian.MarkdownView) {
view.contentEl.querySelectorAll(".zotero-toolbar").forEach((el: Element) => el.remove());
const actionEls = this.titlebarActions.get(view);
if (!actionEls) return;
@ -927,160 +929,6 @@ export default class ZoteroCitations extends obsidian.Plugin {
if (!locator) return locator;
return locator.replace(/^(p\.|para\.|sec\.|ch\.|fig\.|table|v\.|l\.|n\.|col\.|no\.|vol\.)\s+/i, "");
}
// ── CSS ───────────────────────────────────────────────────────────────────
injectStyles() {
this.styleEl?.remove();
const s = document.createElement("style");
s.id = "zotero-wi-styles";
s.textContent = `
/* Word-style footnote marker */
.zotero-fn-widget {
display: inline;
vertical-align: baseline;
}
sup.zotero-fn-num {
line-height: 0;
}
sup.zotero-fn-num > .zotero-footnote-marker {
color: var(--text-accent) !important;
display: inline;
font-size: 0.75em;
font-weight: 500;
cursor: pointer;
font-family: var(--font-text);
pointer-events: auto;
text-decoration: none !important;
box-shadow: none !important;
border: none !important;
}
sup.zotero-fn-num > .zotero-footnote-marker:hover,
sup.zotero-fn-num > .zotero-footnote-marker:focus-visible {
color: var(--text-accent-hover, var(--text-accent)) !important;
text-decoration: none !important;
outline: none;
}
.zotero-fn-widget.zotero-fn-highlighted {
background: var(--text-highlight-bg) !important;
color: var(--text-normal) !important;
box-shadow: none !important;
border: none !important;
}
.zotero-fn-widget.zotero-fn-highlighted > sup.zotero-fn-num,
.zotero-fn-widget.zotero-fn-highlighted > sup.zotero-fn-num > .zotero-footnote-marker,
mark .zotero-rendered-footnote-sup,
mark .zotero-rendered-footnote-ref {
background: transparent !important;
box-shadow: none !important;
border: none !important;
}
.zotero-rendered-footnote-sup {
line-height: 0;
}
a.zotero-rendered-footnote-ref {
color: var(--text-accent);
font-size: 0.75em;
text-decoration: none;
box-shadow: none !important;
border: none !important;
}
.zotero-footnote-hover {
position: fixed;
z-index: 9999;
max-width: min(32rem, calc(100vw - 16px));
padding: 8px 10px;
border-radius: 8px;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
box-shadow: var(--shadow-s);
color: var(--text-normal);
font-size: 0.9em;
line-height: 1.45;
white-space: normal;
pointer-events: none;
}
.zotero-footnote-popover {
max-width: min(36rem, calc(100vw - 16px));
max-height: min(70vh, 42rem);
overflow: auto;
}
.zotero-footnote-popover .markdown-embed {
border: none;
padding: 0;
}
.zotero-footnote-popover .markdown-embed-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.zotero-footnote-popover .markdown-preview-view {
min-height: 0;
padding: 0 12px 2px;
font-size: 0.95em;
}
.zotero-footnote-popover .markdown-preview-view > *:first-child {
margin-top: 0;
}
.zotero-footnote-popover .markdown-preview-view > *:last-child {
margin-bottom: 0;
}
.zotero-footnote-locator-editor {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px 10px;
}
.zotero-footnote-locator-editor input {
flex: 1 1 auto;
min-width: 0;
height: 26px;
padding: 2px 8px;
font-size: 0.9em;
border-radius: 6px;
}
.zotero-footnote-locator-editor > div {
display: flex;
justify-content: flex-end;
}
.zotero-footnote-locator-editor button {
height: 26px;
padding: 2px 8px;
font-size: 0.85em;
border-radius: 6px;
}
/* Connection status dots */
.zotero-status-dot {
display: inline-block; width: 10px; height: 10px;
border-radius: 50%; margin-right: 8px; vertical-align: middle;
}
.zotero-status-ok { background: #4caf50; }
.zotero-status-err { background: #f44336; }
.zotero-status-unknown { background: #9e9e9e; }
.zotero-status-row { display: flex; align-items: center; margin: 6px 0; }
.view-action.zotero-titlebar-action.is-active {
color: var(--text-accent);
}
.view-action.zotero-titlebar-action.is-active svg {
stroke: currentColor;
}
/* Reduce excessive top spacing in plugin modals so title aligns with close button */
.modal:has(.zotero-prefs-modal),
.modal:has(.zotero-unlink-modal),
.modal:has(.zotero-search-modal) {
padding-top: 12px;
}
.zotero-prefs-modal, .zotero-unlink-modal, .zotero-search-modal {
padding-top: 0 !important;
}
.zotero-prefs-modal h2:first-child,
.zotero-unlink-modal h2:first-child,
.zotero-search-modal h2:first-child {
margin-top: 0;
}
`;
document.head.appendChild(s);
this.styleEl = s;
}
}
class ConfirmModal extends obsidian.Modal {
@ -1099,10 +947,9 @@ class ConfirmModal extends obsidian.Modal {
onOpen() {
if (this.cls) this.contentEl.addClass(this.cls);
this.contentEl.createEl("h2", { text: this.title });
this.contentEl.createEl("h2", { text: this.title, cls: "zotero-modal-title" });
this.contentEl.createEl("p", { text: this.msg });
const row = this.contentEl.createDiv({ cls: "zotero-btn-row" });
row.style.cssText = "display:flex;justify-content:flex-end;gap:8px;margin-top:16px";
row.createEl("button", { text: appT(this.app, "common.cancel") }).addEventListener("click", () => this.close());
const ok = row.createEl("button", { text: appT(this.app, "common.confirm"), cls: "mod-warning" });
ok.addEventListener("click", () => {

View file

@ -17,27 +17,20 @@ export class ExportModal extends Modal {
onOpen(): void {
const { contentEl } = this;
contentEl.createEl("h2", { text: appT(this.app, "export.chooseLocation") });
contentEl.createEl("h2", { text: appT(this.app, "export.chooseLocation"), cls: "zotero-modal-title" });
contentEl.createEl("p", {
text: appT(this.app, "export.pathHint"),
cls: "zotero-export-hint",
});
this.input = contentEl.createEl("input", { type: "text" });
this.input = contentEl.createEl("input", { type: "text", cls: "zotero-export-input" });
this.input.value = this.suggestedPath;
this.input.style.width = "100%";
this.input.style.marginBottom = "12px";
this.input.style.padding = "6px 10px";
this.input.style.boxSizing = "border-box";
this.input.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.doConfirm();
if (e.key === "Escape") this.close();
});
const btnRow = contentEl.createDiv({ cls: "zotero-btn-row" });
btnRow.style.display = "flex";
btnRow.style.justifyContent = "flex-end";
btnRow.style.gap = "8px";
const cancel = btnRow.createEl("button", { text: appT(this.app, "common.cancel") });
cancel.addEventListener("click", () => this.close());

View file

@ -40,55 +40,39 @@ export class PreferencesModal extends Modal {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("zotero-prefs-modal");
contentEl.createEl("h2", { text: appT(this.app, "prefs.title") });
contentEl.createEl("h2", { text: appT(this.app, "prefs.title"), cls: "zotero-modal-title" });
// ── Style selector with search (Improvement 2) ──
const styleWrap = contentEl.createDiv();
styleWrap.style.marginBottom = "16px";
const styleWrap = contentEl.createDiv({ cls: "zotero-prefs-section" });
const labelRow = styleWrap.createDiv();
labelRow.style.display = "flex";
labelRow.style.justifyContent = "space-between";
labelRow.style.alignItems = "center";
labelRow.style.marginBottom = "6px";
const labelRow = styleWrap.createDiv({ cls: "zotero-prefs-label-row" });
labelRow.createEl("label", { text: appT(this.app, "prefs.styleLabel") });
const refreshBtn = labelRow.createEl("button", {
text: appT(this.app, "prefs.refreshStyles"),
cls: "clickable-icon",
cls: "clickable-icon zotero-prefs-refresh",
});
refreshBtn.style.fontSize = "0.85em";
refreshBtn.addEventListener("click", () => this.loadStyles(true));
// Search input for styles
this.styleSearchInput = styleWrap.createEl("input", {
type: "text",
placeholder: appT(this.app, "prefs.searchStylePlaceholder"),
cls: "zotero-style-search-input",
});
this.styleSearchInput.style.width = "100%";
this.styleSearchInput.style.marginBottom = "6px";
this.styleSearchInput.style.padding = "4px 8px";
this.styleSearchInput.addEventListener("input", () => this.filterStyles());
// Style list container
this.styleListEl = styleWrap.createDiv({ cls: "zotero-style-list" });
this.styleListEl.style.maxHeight = "200px";
this.styleListEl.style.overflowY = "auto";
this.styleListEl.style.border = "1px solid var(--background-modifier-border)";
this.styleListEl.style.borderRadius = "4px";
this.styleListEl.style.padding = "4px";
// Load styles (dynamic from Zotero + fallback)
await this.loadStyles(false);
// ── Mode selector ──
const modeWrap = contentEl.createDiv();
modeWrap.style.marginBottom = "16px";
modeWrap.createEl("label", { text: appT(this.app, "prefs.modeLabel") }).style.display = "block";
const modeSelect = modeWrap.createEl("select");
modeSelect.style.width = "100%";
modeSelect.style.marginTop = "6px";
const modeWrap = contentEl.createDiv({ cls: "zotero-prefs-section" });
modeWrap.createEl("label", { text: appT(this.app, "prefs.modeLabel"), cls: "zotero-mode-label" });
const modeSelect = modeWrap.createEl("select", { cls: "zotero-mode-select" });
modeSelect.createEl("option", { text: getModeLabel("endnote", getAppSettings(this.app) || DEFAULT_SETTINGS, "option"), value: "endnote" });
modeSelect.createEl("option", { text: getModeLabel("inline", getAppSettings(this.app) || DEFAULT_SETTINGS, "option"), value: "inline" });
modeSelect.value = this.selectedMode;
@ -99,20 +83,12 @@ export class PreferencesModal extends Modal {
// ── Citation count info ──
const infoWrap = contentEl.createDiv({ cls: "zotero-prefs-info" });
infoWrap.style.background = "var(--background-secondary)";
infoWrap.style.padding = "10px";
infoWrap.style.borderRadius = "4px";
infoWrap.style.marginBottom = "16px";
infoWrap.createEl("span", { text: appT(this.app, "prefs.citationCount") });
this.citationsFoundEl = infoWrap.createEl("span", { text: "", cls: "zotero-citation-count" });
this.citationsFoundEl.style.fontWeight = "600";
this.updateCitationCount();
// ── Buttons ──
const btnRow = contentEl.createDiv({ cls: "zotero-btn-row" });
btnRow.style.display = "flex";
btnRow.style.gap = "8px";
btnRow.style.justifyContent = "flex-end";
const cancelBtn = btnRow.createEl("button", { text: appT(this.app, "common.cancel") });
cancelBtn.addEventListener("click", () => this.close());
const applyBtn = btnRow.createEl("button", { text: appT(this.app, "prefs.apply"), cls: "mod-cta" });
@ -168,15 +144,8 @@ export class PreferencesModal extends Modal {
this.styleListEl.empty();
for (const style of styles) {
const item = this.styleListEl.createDiv({ cls: "zotero-style-item" });
item.style.padding = "4px 8px";
item.style.cursor = "pointer";
item.style.borderRadius = "3px";
item.textContent = style.title;
if (style.id === this.selectedStyle) {
item.style.background = "var(--background-modifier-active-hover)";
item.style.fontWeight = "600";
}
item.toggleClass("is-selected", style.id === this.selectedStyle);
item.addEventListener("click", () => {
this.selectedStyle = style.id;
@ -187,12 +156,6 @@ export class PreferencesModal extends Modal {
);
this.updateCitationCount();
});
item.addEventListener("mouseover", () => {
if (style.id !== this.selectedStyle) item.style.background = "var(--background-modifier-hover)";
});
item.addEventListener("mouseout", () => {
if (style.id !== this.selectedStyle) item.style.background = "";
});
}
}

View file

@ -44,7 +44,7 @@ export class SearchModal extends Modal {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("zotero-search-modal");
contentEl.createEl("h2", { text: appT(this.app, "search.title") });
contentEl.createEl("h2", { text: appT(this.app, "search.title"), cls: "zotero-modal-title" });
const styleName = getStyleName(this.opts.style, getAppSettings(this.app) || DEFAULT_SETTINGS);
contentEl.createEl("p", {
@ -58,37 +58,24 @@ export class SearchModal extends Modal {
placeholder: appT(this.app, "search.placeholder"),
cls: "zotero-search-input",
});
this.searchInput.style.width = "100%";
this.searchInput.addEventListener("input", () => this.onSearchInput());
this.resultsEl = contentEl.createDiv({ cls: "zotero-results" });
this.resultsEl.style.maxHeight = "300px";
this.resultsEl.style.overflowY = "auto";
this.resultsEl.style.margin = "8px 0";
this.resultsEl.createEl("p", {
text: appT(this.app, "search.enterQuery"),
cls: "zotero-results-placeholder",
});
const pageWrap = contentEl.createDiv({ cls: "zotero-page-wrap" });
pageWrap.style.display = "flex";
pageWrap.style.alignItems = "center";
pageWrap.style.gap = "8px";
pageWrap.style.marginTop = "8px";
pageWrap.createEl("label", { text: appT(this.app, "search.pageLabel") });
this.pageInput = pageWrap.createEl("input", {
type: "text",
placeholder: appT(this.app, "search.pagePlaceholder"),
cls: "zotero-page-input",
});
this.pageInput.style.flex = "1";
if (this.opts.existingPage) this.pageInput.value = this.opts.existingPage;
const btnRow = contentEl.createDiv({ cls: "zotero-btn-row" });
btnRow.style.display = "flex";
btnRow.style.justifyContent = "flex-end";
btnRow.style.gap = "8px";
btnRow.style.marginTop = "12px";
const cancelBtn = btnRow.createEl("button", { text: appT(this.app, "common.cancel") });
cancelBtn.addEventListener("click", () => this.close());
@ -141,9 +128,6 @@ export class SearchModal extends Modal {
private renderResultItem(item: ZoteroItem): void {
const row = this.resultsEl.createDiv({ cls: "zotero-result-row" });
row.style.padding = "6px 8px";
row.style.cursor = "pointer";
row.style.borderRadius = "4px";
const authors = item.creators
.filter((c) => c.creatorType === "author")
@ -155,29 +139,19 @@ export class SearchModal extends Modal {
const typeLabel = SearchModal.typeLabel(item.itemType, getAppSettings(this.app) || DEFAULT_SETTINGS);
const titleDiv = row.createEl("div", { text: item.title, cls: "zotero-result-title" });
titleDiv.style.fontWeight = "500";
const metaDiv = row.createEl("div", {
text: `${authors}${authors ? " · " : ""}${year} · ${typeLabel}`,
cls: "zotero-result-meta",
});
metaDiv.style.color = "var(--text-muted)";
row.addEventListener("click", () => this.selectItem(item, row));
row.addEventListener("mouseover", () => {
row.style.background = "var(--background-modifier-hover)";
});
row.addEventListener("mouseout", () => {
if (this.selectedItem?.key !== item.key) {
row.style.background = "";
}
});
}
private selectItem(item: ZoteroItem, rowEl: HTMLElement): void {
this.resultsEl.querySelectorAll(".zotero-result-row").forEach((el: Element) => {
(el as HTMLElement).style.background = "";
(el as HTMLElement).removeClass("is-selected");
});
rowEl.style.background = "var(--background-modifier-active-hover)";
rowEl.addClass("is-selected");
this.selectedItem = item;
this.confirmBtn.disabled = false;
@ -190,11 +164,6 @@ export class SearchModal extends Modal {
let previewEl = this.contentEl.querySelector(".zotero-preview") as HTMLElement | null;
if (!previewEl) {
previewEl = this.contentEl.createDiv({ cls: "zotero-preview" });
previewEl.style.background = "var(--background-secondary)";
previewEl.style.padding = "6px 10px";
previewEl.style.borderRadius = "4px";
previewEl.style.marginTop = "8px";
previewEl.style.fontStyle = "italic";
}
previewEl.setText(appT(this.app, "search.preview", { preview }));

View file

@ -101,10 +101,9 @@ export class ZoteroSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Zotero Citations" });
// ── Interface language ──
containerEl.createEl("h3", { text: t(this.plugin.settings, "settings.interface") });
new Setting(containerEl).setName(t(this.plugin.settings, "settings.interface")).setHeading();
new Setting(containerEl)
.setName(t(this.plugin.settings, "settings.interface"))
.setDesc(t(this.plugin.settings, "settings.interfaceDesc"))
@ -121,17 +120,19 @@ export class ZoteroSettingTab extends PluginSettingTab {
});
// ── Connection status ──
containerEl.createEl("h3", { text: t(this.plugin.settings, "settings.connection") });
new Setting(containerEl).setName(t(this.plugin.settings, "settings.connection")).setHeading();
const row = containerEl.createDiv({ cls: "zotero-status-row" });
this.statusDot = row.createSpan({ cls: "zotero-status-dot zotero-status-unknown" });
this.statusText = row.createSpan({ text: t(this.plugin.settings, "settings.checking") });
const btn = containerEl.createEl("button", { text: t(this.plugin.settings, "settings.recheck") });
btn.style.marginTop = "4px";
const btn = containerEl.createEl("button", {
text: t(this.plugin.settings, "settings.recheck"),
cls: "zotero-settings-check-button",
});
btn.addEventListener("click", () => this.checkConnection());
this.checkConnection();
// ── Citation style ──
containerEl.createEl("h3", { text: t(this.plugin.settings, "settings.citationStyleSection") });
new Setting(containerEl).setName(t(this.plugin.settings, "settings.citationStyleSection")).setHeading();
new Setting(containerEl)
.setName(t(this.plugin.settings, "settings.defaultStyle"))
.setDesc(t(this.plugin.settings, "settings.defaultStyleDesc"))
@ -176,7 +177,7 @@ export class ZoteroSettingTab extends PluginSettingTab {
});
// ── Editor display ──
containerEl.createEl("h3", { text: t(this.plugin.settings, "settings.editorDisplaySection") });
new Setting(containerEl).setName(t(this.plugin.settings, "settings.editorDisplaySection")).setHeading();
new Setting(containerEl)
.setName(t(this.plugin.settings, "settings.wordDisplay"))
.setDesc(t(this.plugin.settings, "settings.wordDisplayDesc"))
@ -206,10 +207,6 @@ export class ZoteroSettingTab extends PluginSettingTab {
// ── Improvement 3: Individual toolbar button toggles ──
if (this.plugin.settings.showToolbar) {
const toolbarSection = containerEl.createDiv({ cls: "zotero-toolbar-buttons-section" });
toolbarSection.style.paddingLeft = "24px";
toolbarSection.style.borderLeft = "2px solid var(--background-modifier-border)";
toolbarSection.style.marginLeft = "12px";
toolbarSection.style.marginBottom = "12px";
const buttonKeys: { key: keyof ToolbarButtons; labelKey: string }[] = [
{ key: "export", labelKey: "settings.toolbarBtn.export" },
@ -235,7 +232,7 @@ export class ZoteroSettingTab extends PluginSettingTab {
}
// ── Export section ──
containerEl.createEl("h3", { text: t(this.plugin.settings, "settings.exportSection") });
new Setting(containerEl).setName(t(this.plugin.settings, "settings.exportSection")).setHeading();
new Setting(containerEl)
.setName(t(this.plugin.settings, "settings.pandocPath"))
.setDesc(t(this.plugin.settings, "settings.pandocPathDesc"))
@ -278,12 +275,11 @@ export class ZoteroSettingTab extends PluginSettingTab {
}
// ── Command list ──
containerEl.createEl("h3", { text: t(this.plugin.settings, "settings.commandsSection") });
new Setting(containerEl).setName(t(this.plugin.settings, "settings.commandsSection")).setHeading();
const cmds = Object.values(this.plugin.getCommandLabels()) as string[];
const ul = containerEl.createEl("ul");
for (const c of cmds) {
const li = ul.createEl("li", { text: c });
li.style.color = "var(--text-muted)";
ul.createEl("li", { text: c, cls: "zotero-settings-command" });
}
}
@ -295,7 +291,7 @@ export class ZoteroSettingTab extends PluginSettingTab {
url: `http://127.0.0.1:${this.plugin.settings.zoteroPort}/connector/ping`,
method: "GET",
throw: false,
} as any);
});
if (r.status === 200) {
this.statusDot.className = "zotero-status-dot zotero-status-ok";
this.statusText.textContent = t(this.plugin.settings, "status.connected");

316
styles.css Normal file
View file

@ -0,0 +1,316 @@
/* Word-style footnote marker */
.zotero-fn-widget {
display: inline;
vertical-align: baseline;
}
sup.zotero-fn-num,
.zotero-rendered-footnote-sup {
line-height: 0;
}
sup.zotero-fn-num > .zotero-footnote-marker {
color: var(--text-accent) !important;
display: inline;
font-size: 0.75em;
font-weight: 500;
cursor: pointer;
font-family: var(--font-text);
pointer-events: auto;
text-decoration: none !important;
box-shadow: none !important;
border: none !important;
}
sup.zotero-fn-num > .zotero-footnote-marker:hover,
sup.zotero-fn-num > .zotero-footnote-marker:focus-visible {
color: var(--text-accent-hover, var(--text-accent)) !important;
text-decoration: none !important;
outline: none;
}
.zotero-fn-widget.zotero-fn-highlighted {
background: var(--text-highlight-bg) !important;
color: var(--text-normal) !important;
box-shadow: none !important;
border: none !important;
}
.zotero-fn-widget.zotero-fn-highlighted > sup.zotero-fn-num,
.zotero-fn-widget.zotero-fn-highlighted > sup.zotero-fn-num > .zotero-footnote-marker,
mark .zotero-rendered-footnote-sup,
mark .zotero-rendered-footnote-ref {
background: transparent !important;
box-shadow: none !important;
border: none !important;
}
a.zotero-rendered-footnote-ref {
color: var(--text-accent);
font-size: 0.75em;
text-decoration: none;
box-shadow: none !important;
border: none !important;
}
.zotero-footnote-popover {
position: fixed;
z-index: 9999;
max-width: min(36rem, calc(100vw - 16px));
max-height: min(70vh, 42rem);
overflow: auto;
}
.zotero-footnote-popover .markdown-embed {
border: none;
padding: 0;
}
.zotero-footnote-popover .markdown-embed-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.zotero-footnote-popover .markdown-preview-view {
min-height: 0;
padding: 0 12px 2px;
font-size: 0.95em;
}
.zotero-footnote-popover .markdown-preview-view > *:first-child,
.zotero-modal-title {
margin-top: 0;
}
.zotero-footnote-popover .markdown-preview-view > *:last-child {
margin-bottom: 0;
}
.zotero-footnote-locator-editor {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px 10px;
}
.zotero-footnote-locator-editor input {
flex: 1 1 auto;
min-width: 0;
height: 26px;
padding: 2px 8px;
font-size: 0.9em;
border-radius: 6px;
}
.zotero-footnote-locator-editor > div,
.zotero-btn-row {
display: flex;
justify-content: flex-end;
}
.zotero-footnote-locator-editor button {
height: 26px;
padding: 2px 8px;
font-size: 0.85em;
border-radius: 6px;
}
/* Settings */
.zotero-status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}
.zotero-status-ok {
background: var(--color-green);
}
.zotero-status-err {
background: var(--color-red);
}
.zotero-status-unknown {
background: var(--text-muted);
}
.zotero-status-row {
display: flex;
align-items: center;
margin: 6px 0;
}
.zotero-settings-check-button {
margin-top: 4px;
}
.zotero-toolbar-buttons-section {
padding-left: 24px;
border-left: 2px solid var(--background-modifier-border);
margin-left: 12px;
margin-bottom: 12px;
}
.zotero-settings-command {
color: var(--text-muted);
}
/* Title bar icons */
.view-action.zotero-titlebar-action.is-active {
color: var(--text-accent);
}
.view-action.zotero-titlebar-action.is-active svg {
stroke: currentColor;
}
/* Modals */
.modal:has(.zotero-prefs-modal),
.modal:has(.zotero-unlink-modal),
.modal:has(.zotero-search-modal) {
padding-top: 12px;
}
.zotero-prefs-modal,
.zotero-unlink-modal,
.zotero-search-modal {
padding-top: 0 !important;
}
.zotero-prefs-section {
margin-bottom: 16px;
}
.zotero-prefs-label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.zotero-prefs-refresh {
font-size: 0.85em;
}
.zotero-style-search-input,
.zotero-mode-select,
.zotero-search-input,
.zotero-export-input {
width: 100%;
}
.zotero-style-search-input {
margin-bottom: 6px;
padding: 4px 8px;
}
.zotero-style-list,
.zotero-results {
overflow-y: auto;
}
.zotero-style-list {
max-height: 200px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
padding: 4px;
}
.zotero-mode-label {
display: block;
}
.zotero-mode-select {
margin-top: 6px;
}
.zotero-prefs-info,
.zotero-preview {
background: var(--background-secondary);
border-radius: 4px;
}
.zotero-prefs-info {
padding: 10px;
margin-bottom: 16px;
}
.zotero-citation-count {
font-weight: 600;
}
.zotero-btn-row {
gap: 8px;
margin-top: 12px;
}
.zotero-style-item,
.zotero-result-row {
cursor: pointer;
border-radius: 4px;
}
.zotero-style-item {
padding: 4px 8px;
border-radius: 3px;
}
.zotero-style-item:hover,
.zotero-result-row:hover {
background: var(--background-modifier-hover);
}
.zotero-style-item.is-selected,
.zotero-result-row.is-selected {
background: var(--background-modifier-active-hover);
}
.zotero-style-item.is-selected {
font-weight: 600;
}
.zotero-results {
max-height: 300px;
margin: 8px 0;
}
.zotero-page-wrap {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.zotero-page-input {
flex: 1;
}
.zotero-result-row {
padding: 6px 8px;
}
.zotero-result-title {
font-weight: 500;
}
.zotero-result-meta,
.zotero-results-placeholder,
.zotero-style-hint {
color: var(--text-muted);
}
.zotero-preview {
padding: 6px 10px;
margin-top: 8px;
font-style: italic;
}
.zotero-export-input {
margin-bottom: 12px;
padding: 6px 10px;
box-sizing: border-box;
}

View file

@ -1,3 +1,4 @@
{
"0.2.0": "1.5.7"
"0.2.0": "1.5.7",
"0.2.1": "1.5.7"
}