fix: prepare 0.1.5 release

This commit is contained in:
flash555588 2026-05-13 02:03:30 +08:00
parent 6c6c632493
commit c0629c7d17
11 changed files with 26005 additions and 71 deletions

2
.gitignore vendored
View file

@ -1,7 +1,5 @@
node_modules/
main.js
main.js.map
data.json
package-lock.json
_pr_work/
test-annotation.md

View file

@ -19,7 +19,7 @@ No test runner is configured. The only verification gate is `typecheck`.
## Architecture
Entry point: `src/main.ts` → esbuild bundles to `main.js` (CJS, ES2018). `main.js` + `manifest.json` + `styles.css` are deployed to the Obsidian vault's `.obsidian/plugins/ai-3d-model-workbench/`.
Entry point: `src/main.ts` → esbuild bundles to `main.js` (CJS, ES2018). `main.js` + `manifest.json` + `styles.css` are deployed to the Obsidian vault's `.obsidian/plugins/ai-three-d-model-workbench/`.
### Layer Layout

View file

@ -69,7 +69,7 @@ npm run build
3. Create this folder inside the vault:
```text
<your-vault>/.obsidian/plugins/ai-3d-model-workbench/
<your-vault>/.obsidian/plugins/ai-three-d-model-workbench/
```
4. Copy `main.js`, `manifest.json`, and `styles.css` into that folder.
@ -99,7 +99,7 @@ Use [Quick Start](#quick-start) if you only want the fastest setup.
- This plugin folder inside the vault:
```text
<vault>/.obsidian/plugins/ai-3d-model-workbench/
<vault>/.obsidian/plugins/ai-three-d-model-workbench/
```
All install methods place the same three files in that folder:
@ -123,27 +123,27 @@ npm install
npm run build
```
2. Create `<vault>/.obsidian/plugins/ai-3d-model-workbench/` if it does not exist.
2. Create `<vault>/.obsidian/plugins/ai-three-d-model-workbench/` if it does not exist.
3. Copy `main.js`, `manifest.json`, and `styles.css` into that folder.
4. In Obsidian, enable `AI 3D Model Workbench` in `Settings > Community Plugins`.
### Option B: Download a Release
1. Download `main.js`, `manifest.json`, and `styles.css` from [Releases](https://github.com/flash555588/ai-3d-model-workbench/releases).
2. Create `<vault>/.obsidian/plugins/ai-3d-model-workbench/` if it does not exist.
2. Create `<vault>/.obsidian/plugins/ai-three-d-model-workbench/` if it does not exist.
3. Put the three files in that folder.
4. In Obsidian, enable `AI 3D Model Workbench` in `Settings > Community Plugins`.
### Option C: Symlink for Development
1. Make sure `<vault>/.obsidian/plugins/` already exists.
2. Create a symlink named `ai-3d-model-workbench` that points to this repository.
2. Create a symlink named `ai-three-d-model-workbench` that points to this repository.
Windows (PowerShell):
```powershell
New-Item -ItemType SymbolicLink `
-Path "C:\path\to\your-vault\.obsidian\plugins\ai-3d-model-workbench" `
-Path "C:\path\to\your-vault\.obsidian\plugins\ai-three-d-model-workbench" `
-Target "C:\path\to\ai-3d-model-workbench"
```
@ -151,7 +151,7 @@ macOS / Linux:
```bash
ln -s /path/to/ai-3d-model-workbench \
/path/to/your-vault/.obsidian/plugins/ai-3d-model-workbench
/path/to/your-vault/.obsidian/plugins/ai-three-d-model-workbench
```
3. In this repository, run `npm install` once if needed.
@ -569,7 +569,7 @@ npm run typecheck # TypeScript type check
### Build Output
```
ai-3d-model-workbench/
ai-three-d-model-workbench/
├── main.js # 1.7 MB (minified, Babylon.js core)
├── manifest.json # Plugin manifest
├── styles.css # Plugin styles

View file

@ -69,7 +69,7 @@ npm run build
3. 在该 vault 里创建这个文件夹:
```text
<your-vault>/.obsidian/plugins/ai-3d-model-workbench/
<your-vault>/.obsidian/plugins/ai-three-d-model-workbench/
```
4. 把 `main.js`、`manifest.json`、`styles.css` 复制到这个文件夹里。
@ -99,7 +99,7 @@ npm run build
- vault 里的插件目录:
```text
<vault>/.obsidian/plugins/ai-3d-model-workbench/
<vault>/.obsidian/plugins/ai-three-d-model-workbench/
```
无论用哪种方式安装,最终都要把下面这三个文件放进这个目录:
@ -123,27 +123,27 @@ npm install
npm run build
```
2. 如果 `<vault>/.obsidian/plugins/ai-3d-model-workbench/` 还不存在,先创建它。
2. 如果 `<vault>/.obsidian/plugins/ai-three-d-model-workbench/` 还不存在,先创建它。
3. 把 `main.js`、`manifest.json`、`styles.css` 复制到这个文件夹里。
4. 在 Obsidian 的“设置 > 社区插件”中启用 `AI 3D Model Workbench`
### 方式 B下载发布版
1. 从 [Releases](https://github.com/flash555588/ai-3d-model-workbench/releases) 下载 `main.js`、`manifest.json` 和 `styles.css`
2. 如果 `<vault>/.obsidian/plugins/ai-3d-model-workbench/` 还不存在,先创建它。
2. 如果 `<vault>/.obsidian/plugins/ai-three-d-model-workbench/` 还不存在,先创建它。
3. 把这三个文件放进这个文件夹里。
4. 在 Obsidian 的“设置 > 社区插件”中启用 `AI 3D Model Workbench`
### 方式 C开发用符号链接
1. 先确认 `<vault>/.obsidian/plugins/` 已经存在。
2. 创建一个名为 `ai-3d-model-workbench` 的符号链接,指向当前仓库。
2. 创建一个名为 `ai-three-d-model-workbench` 的符号链接,指向当前仓库。
WindowsPowerShell
```powershell
New-Item -ItemType SymbolicLink `
-Path "C:\path\to\your-vault\.obsidian\plugins\ai-3d-model-workbench" `
-Path "C:\path\to\your-vault\.obsidian\plugins\ai-three-d-model-workbench" `
-Target "C:\path\to\ai-3d-model-workbench"
```
@ -151,7 +151,7 @@ macOS / Linux
```bash
ln -s /path/to/ai-3d-model-workbench \
/path/to/your-vault/.obsidian/plugins/ai-3d-model-workbench
/path/to/your-vault/.obsidian/plugins/ai-three-d-model-workbench
```
3. 如果还没装依赖,先在当前仓库运行一次 `npm install`
@ -228,7 +228,7 @@ ln -s /path/to/ai-3d-model-workbench \
```3d model.glb
```
````
ai-three-d-model-workbench/
**完整配置** — 相机、灯光、场景、多模型:
````markdown

20578
main.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,9 @@
{
"id": "ai-3d-model-workbench",
"id": "ai-three-d-model-workbench",
"name": "AI 3D Model Workbench",
"version": "0.1.4",
"version": "0.1.5",
"minAppVersion": "1.5.0",
"description": "Turn 3D models into linked knowledge assets.",
"description": "Turn 3D models into linked knowledge assets.",
"author": "flash",
"authorUrl": "https://github.com/flash555588",
"isDesktopOnly": false

5258
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"name": "obsidian-ai-3d-model-workbench",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"description": "Obsidian plugin for turning 3D models into linked knowledge assets.",
"description": "Obsidian plugin for turning 3D models into linked knowledge assets.",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "node esbuild.config.mjs production",

View file

@ -123,8 +123,12 @@ export default class AI3DModelWorkbench extends Plugin {
".cm-header-4", ".cm-header-5", ".cm-header-6",
].join(", ");
// Track bound elements for cleanup
const boundEntries: { el: Element; handler: () => void }[] = [];
type BoundHeadingEntry = {
badge: HTMLSpanElement;
handler: () => void;
signature: string;
};
const boundEntries = new Map<Element, BoundHeadingEntry>();
// Build headingRef → [{ pinId, modelPath, color }] map from stored annotations.
// Supports multiple pins/models per heading.
@ -146,8 +150,10 @@ export default class AI3DModelWorkbench extends Plugin {
for (const [modelPath, profile] of Object.entries(profiles)) {
for (const pin of profile.annotations) {
if (pin.headingRef && pin.id) {
let arr = map.get(pin.headingRef);
if (!arr) { arr = []; map.set(pin.headingRef, arr); }
const headingKey = normalizeHeadingText(pin.headingRef);
if (!headingKey) continue;
let arr = map.get(headingKey);
if (!arr) { arr = []; map.set(headingKey, arr); }
arr.push({ pinId: pin.id, modelPath, color: pin.color });
}
}
@ -155,24 +161,51 @@ export default class AI3DModelWorkbench extends Plugin {
return map;
};
// Normalize all headingRef keys for robust matching
const normalizedMap = new Map<string, PinEntry[]>();
const buildNormalizedMap = (headingMap: Map<string, PinEntry[]>): void => {
normalizedMap.clear();
for (const [key, entries] of headingMap) {
normalizedMap.set(normalizeHeadingText(key), entries);
}
const buildEntriesSignature = (entries: PinEntry[]): string => entries
.map((entry) => `${entry.pinId}:${entry.modelPath}:${entry.color}`)
.sort()
.join("|");
const buildHeadingMapSignature = (headingMap: Map<string, PinEntry[]>): string => Array
.from(headingMap.entries())
.map(([key, entries]) => `${key}=>${buildEntriesSignature(entries)}`)
.sort()
.join("||");
const getHeadingText = (el: Element): string => normalizeHeadingText(
Array.from(el.childNodes)
.map((node) => {
if (node.instanceOf(Element) && node.classList.contains("ai3d-heading-pin-badge")) {
return "";
}
return node.textContent ?? "";
})
.join(" "),
);
const unbindHeading = (el: Element): void => {
const existing = boundEntries.get(el);
if (!existing) return;
el.removeEventListener("mouseover", existing.handler);
existing.badge.remove();
delete (el as HTMLElement).dataset.pinBound;
boundEntries.delete(el);
};
const bindHeading = (el: Element) => {
if ((el as HTMLElement).dataset.pinBound) return;
const bindHeading = (el: Element, entries: PinEntry[]): void => {
if (entries.length === 0) {
unbindHeading(el);
return;
}
const text = el.textContent ?? "";
const headingText = normalizeHeadingText(text);
const entries = normalizedMap.get(headingText);
const signature = buildEntriesSignature(entries);
const existing = boundEntries.get(el);
if (existing?.signature === signature) return;
if (existing) {
unbindHeading(el);
}
if (!entries || entries.length === 0) return;
(el as HTMLElement).dataset.pinBound = entries[0].pinId;
(el as HTMLElement).dataset.pinBound = signature;
// Add pin badge: shows count if multiple, tooltip lists model sources
// Create on heading element (in DOM) to inherit Obsidian CSS variables
@ -216,32 +249,72 @@ export default class AI3DModelWorkbench extends Plugin {
}
};
el.addEventListener("mouseover", handler);
boundEntries.push({ el, handler });
boundEntries.set(el, { badge, handler, signature });
};
const processHeadings = (container: Element) => {
const syncHeadingElement = (el: Element, headingMap: Map<string, PinEntry[]>): void => {
const headingText = getHeadingText(el);
bindHeading(el, headingMap.get(headingText) ?? []);
};
const reconcileBoundHeadings = (headingMap: Map<string, PinEntry[]>): void => {
for (const [el, entry] of Array.from(boundEntries.entries())) {
if (!el.isConnected) {
unbindHeading(el);
continue;
}
const nextEntries = headingMap.get(getHeadingText(el)) ?? [];
const nextSignature = buildEntriesSignature(nextEntries);
if (nextEntries.length === 0 || entry.signature !== nextSignature) {
bindHeading(el, nextEntries);
}
}
};
const processHeadings = (container: Element, headingMap: Map<string, PinEntry[]>): void => {
container.querySelectorAll(headingSelector).forEach((el) => syncHeadingElement(el, headingMap));
};
const scanAll = (): void => {
const headingMap = buildHeadingMap();
if (headingMap.size === 0) return;
buildNormalizedMap(headingMap);
container.querySelectorAll(headingSelector).forEach((el) => bindHeading(el));
reconcileBoundHeadings(headingMap);
const containers = activeDocument.querySelectorAll(markdownContainerSelector);
containers.forEach((container) => processHeadings(container, headingMap));
};
const scanAll = () => {
const containers = activeDocument.querySelectorAll(markdownContainerSelector);
containers.forEach(processHeadings);
let lastHeadingMapSignature = buildHeadingMapSignature(buildHeadingMap());
let scanTimer = 0;
const scheduleScan = (delay = 0): void => {
if (scanTimer) {
activeWindow.clearTimeout(scanTimer);
}
scanTimer = activeWindow.setTimeout(() => {
scanTimer = 0;
scanAll();
}, delay);
};
const unsubscribeStore = this.ps.store.subscribe(() => {
const nextHeadingMap = buildHeadingMap();
const nextSignature = buildHeadingMapSignature(nextHeadingMap);
if (nextSignature === lastHeadingMapSignature) return;
lastHeadingMapSignature = nextSignature;
scheduleScan();
});
this.registerEvent(this.app.workspace.on("layout-change", () => {
activeWindow.setTimeout(scanAll, 200);
scheduleScan(200);
}));
// Debounced MutationObserver: coalesce rapid DOM changes into a single scan
const isRelevantAddedNode = (node: HTMLElement): boolean => {
if (!node.isConnected) return false;
const matchesRelevantNode = (node: HTMLElement): boolean => {
if (node.matches(markdownContainerSelector) || node.matches(headingSelector)) return true;
return !!node.querySelector(markdownContainerSelector) || !!node.querySelector(headingSelector);
};
const isRelevantAddedNode = (node: HTMLElement): boolean => node.isConnected && matchesRelevantNode(node);
const isRelevantRemovedNode = (node: HTMLElement): boolean => matchesRelevantNode(node);
let pendingNodes = new Set<HTMLElement>();
let debounceTimer = 0;
const flushPending = () => {
@ -249,24 +322,35 @@ export default class AI3DModelWorkbench extends Plugin {
pendingNodes.clear();
debounceTimer = 0;
const headingMap = buildHeadingMap();
if (headingMap.size === 0) return;
buildNormalizedMap(headingMap);
reconcileBoundHeadings(headingMap);
for (const node of nodes) {
if (!node.isConnected) continue;
if (node.matches?.(headingSelector)) bindHeading(node);
node.querySelectorAll?.(headingSelector)?.forEach((el: Element) => bindHeading(el));
if (node.matches?.(headingSelector)) syncHeadingElement(node, headingMap);
node.querySelectorAll?.(headingSelector)?.forEach((el: Element) => syncHeadingElement(el, headingMap));
if (node.matches?.(markdownContainerSelector)) {
processHeadings(node, headingMap);
}
node.querySelectorAll?.(markdownContainerSelector)?.forEach((el: Element) => processHeadings(el, headingMap));
}
lastHeadingMapSignature = buildHeadingMapSignature(headingMap);
};
const observer = new MutationObserver((mutations) => {
let shouldFlush = false;
for (const m of mutations) {
for (const node of Array.from(m.addedNodes)) {
if (!node.instanceOf(HTMLElement)) continue;
if (!isRelevantAddedNode(node)) continue;
pendingNodes.add(node);
shouldFlush = true;
}
for (const node of Array.from(m.removedNodes)) {
if (!node.instanceOf(HTMLElement)) continue;
if (!isRelevantRemovedNode(node)) continue;
shouldFlush = true;
}
}
if (pendingNodes.size > 0 && !debounceTimer) {
if (shouldFlush && !debounceTimer) {
debounceTimer = activeWindow.setTimeout(flushPending, 100);
}
});
@ -274,17 +358,17 @@ export default class AI3DModelWorkbench extends Plugin {
// Cleanup: disconnect observer, remove all heading listeners
this.register(() => {
unsubscribeStore();
observer.disconnect();
if (debounceTimer) { activeWindow.clearTimeout(debounceTimer); debounceTimer = 0; }
for (const { el, handler } of boundEntries) {
el.removeEventListener("mouseover", handler);
el.removeEventListener("click", handler);
if (scanTimer) { activeWindow.clearTimeout(scanTimer); scanTimer = 0; }
for (const el of Array.from(boundEntries.keys())) {
unbindHeading(el);
}
boundEntries.length = 0;
});
// Initial scan
activeWindow.setTimeout(scanAll, 500);
scheduleScan(500);
}
private async activateView() {

View file

@ -62,6 +62,7 @@ export class AnnotationManager {
private static readonly _scratchRay = new Ray(Vector3.Zero(), Vector3.Zero(), 1);
private hoverPopover: HTMLDivElement | null = null;
private hoverTimeout: ReturnType<typeof activeWindow.setTimeout> | null = null;
private hoverRequestId = 0;
private _highlightHandler: ((e: Event) => void) | null = null;
private _pulseTimeout: ReturnType<typeof activeWindow.setTimeout> | null = null;
private _headingDropdown: HTMLDivElement | null = null;
@ -471,22 +472,31 @@ export class AnnotationManager {
await this.renderPreviewContent(el, content, notePath, "editor");
}
private async showHoverPopover(pinEl: HTMLDivElement, pin: AnnotationPin): Promise<void> {
private async showHoverPopover(requestId: number, pinEl: HTMLDivElement, pin: AnnotationPin): Promise<void> {
if (!this.noteReader || !pin.notePath || !pin.headingRef) return;
this.hideHoverPopover();
const content = await this.noteReader(pin.notePath, pin.headingRef);
if (!content) return;
if (!content || requestId !== this.hoverRequestId || !pinEl.isConnected || !this.hostEl.isConnected) return;
// Create on hostEl (in DOM) to inherit Obsidian CSS variables
const popover = this.hostEl.createDiv({ cls: "ai3d-pin-popover" });
const popover = activeDocument.createElement("div");
popover.className = "ai3d-pin-popover";
const title = popover.createDiv({ cls: "ai3d-pin-popover-title" });
const title = activeDocument.createElement("div");
title.className = "ai3d-pin-popover-title";
title.textContent = pin.headingRef;
popover.appendChild(title);
const body = popover.createDiv({ cls: "ai3d-pin-popover-body" });
const body = activeDocument.createElement("div");
body.className = "ai3d-pin-popover-body";
popover.appendChild(body);
await this.renderPreviewContent(body, content, pin.notePath, "popover");
if (requestId !== this.hoverRequestId || !pinEl.isConnected || !this.hostEl.isConnected) {
this.clearRenderedPreview(body);
return;
}
// Position relative to pin
const rect = pinEl.getBoundingClientRect();
popover.style.setProperty("--popover-left", `${rect.left + rect.width / 2}px`);
@ -576,6 +586,7 @@ export class AnnotationManager {
}
destroy(): void {
this.hoverRequestId++;
this.hideHoverPopover();
if (this._pulseTimeout) { activeWindow.clearTimeout(this._pulseTimeout); this._pulseTimeout = null; }
if (this._headingDebounce) { activeWindow.clearTimeout(this._headingDebounce); this._headingDebounce = null; }
@ -629,12 +640,17 @@ export class AnnotationManager {
// Hover popover for linked notes
if (pin.notePath && pin.headingRef && this.noteReader) {
el.addEventListener("mouseenter", () => {
if (this.hoverTimeout) { activeWindow.clearTimeout(this.hoverTimeout); }
const requestId = ++this.hoverRequestId;
this.hoverTimeout = activeWindow.setTimeout(() => {
void this.showHoverPopover(el, pin);
this.hoverTimeout = null;
if (requestId !== this.hoverRequestId) return;
void this.showHoverPopover(requestId, el, pin);
}, 300);
});
el.addEventListener("mouseleave", () => {
if (this.hoverTimeout) { activeWindow.clearTimeout(this.hoverTimeout); this.hoverTimeout = null; }
this.hoverRequestId++;
this.hideHoverPopover();
});
}

View file

@ -1 +1 @@
{"0.0.1":"1.5.0","0.0.2":"1.5.0","0.0.3":"1.5.0","0.0.4":"1.5.0","0.0.5-beta.0":"1.5.0","0.1.1":"1.5.0","0.1.2":"1.5.0","0.1.4":"1.5.0"}
{"0.0.1":"1.5.0","0.0.2":"1.5.0","0.0.3":"1.5.0","0.0.4":"1.5.0","0.0.5-beta.0":"1.5.0","0.1.1":"1.5.0","0.1.2":"1.5.0","0.1.4":"1.5.0","0.1.5":"1.5.0"}