feat: embed model viewer

This commit is contained in:
Janis Pritzkau 2025-03-28 14:53:03 +01:00
parent fc176357cb
commit 54228294d1
6 changed files with 344 additions and 12 deletions

View file

@ -1,3 +1,13 @@
# Obsidian Model Viewer
This plugin allows you to view and embed interactive 3D models directly in your [Obsidian](http://obsidian.md/) vault, powered by Googles [\<model-viewer\>](https://modelviewer.dev/) component.
![Screenshot](screenshot.avif)
> \<model-viewer\> supports and plans to support only glTF/GLB 3D models. It is the Khronos standard known as the JPEG of 3D and the first format to standardize Physically-Based Rendering (PBR), making your models look realistic under any lighting, on any renderer. It is also compact, compressible, and loads rapidly into the GPU.
[\<model-viewer\> FAQ](https://modelviewer.dev/docs/faq.html)
## Features
==TODO==

204
main.ts
View file

@ -1,11 +1,33 @@
import { App, Plugin, PluginSettingTab, Setting } from "obsidian";
import type { ModelViewerElement } from "@google/model-viewer";
import {
App,
Component,
FileView,
Plugin,
PluginSettingTab,
Setting,
type TFile,
type WorkspaceLeaf,
} from "obsidian";
if (customElements.get("model-viewer") == null) {
import("@google/model-viewer");
}
interface ModelViewerSettings {
mySetting: string;
cameraControls: boolean;
disablePan: boolean;
disableZoom: boolean;
autoRotate: boolean;
interactionPrompt: boolean;
}
const DEFAULT_SETTINGS: ModelViewerSettings = {
mySetting: "default",
cameraControls: true,
disablePan: false,
disableZoom: false,
autoRotate: true,
interactionPrompt: true,
};
export default class ModelViewerPlugin extends Plugin {
@ -15,10 +37,31 @@ export default class ModelViewerPlugin extends Plugin {
await this.loadSettings();
this.addSettingTab(new ModelViewerSettingTab(this.app, this));
this.registerView("model-viewer-file-view", (leaf) => {
return new ModelViewerFileView(leaf, this);
});
this.registerExtensions(["glb", "gltf"], "model-viewer-file-view");
// @ts-expect-error
this.app.embedRegistry.registerExtensions(
["gltf", "glb"],
(
context: {
containerEl: HTMLElement;
displayMode: boolean;
linktext: string;
showInline: boolean;
},
file: TFile
) => new ModelViewerComponent(this, context.containerEl, file)
);
}
onunload() {
//
// @ts-expect-error
this.app.embedRegistry.unregisterExtensions(["gltf", "glb"]);
}
async loadSettings() {
@ -30,6 +73,97 @@ export default class ModelViewerPlugin extends Plugin {
}
}
class ModelViewerComponent extends Component {
plugin: ModelViewerPlugin;
containerEl: HTMLElement;
file: TFile;
viewer: ModelViewerElement;
constructor(plugin: ModelViewerPlugin, containerEl: HTMLElement, file: TFile) {
super();
this.plugin = plugin;
this.containerEl = containerEl;
this.file = file;
containerEl.addClass("model-viewer-embed");
this.updateWidth();
this.observer = new MutationObserver(() => {
this.updateWidth();
});
this.observer.observe(this.containerEl, {
attributes: true,
attributeFilter: ["width"],
});
}
onunload(): void {
this.observer.disconnect();
this.viewer?.remove();
}
observer: MutationObserver;
unload(): void {
this.observer.disconnect();
}
updateWidth() {
const width = this.containerEl.getAttribute("width");
this.containerEl.style.width = width ? `${width}px` : "100%";
}
loadFile() {
this.viewer = document.createElement("model-viewer");
this.viewer.src = this.plugin.app.vault.getResourcePath(this.file);
this.containerEl.appendChild(this.viewer);
const settings = this.plugin.settings;
if (settings.cameraControls) this.viewer.cameraControls = true;
if (settings.disablePan) this.viewer.disablePan = true;
if (settings.disableZoom) this.viewer.disableZoom = true;
if (settings.autoRotate) this.viewer.autoRotate = true;
if (!settings.interactionPrompt) this.viewer.interactionPrompt = "none";
}
}
class ModelViewerFileView extends FileView {
private plugin: ModelViewerPlugin;
constructor(leaf: WorkspaceLeaf, plugin: ModelViewerPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string {
return "model-viewer-file-view";
}
canAcceptExtension(extension: string): boolean {
return ["glb", "gltf"].includes(extension);
}
async onLoadFile(file: TFile): Promise<void> {
this.displayInEl(file);
}
displayInEl(file: TFile) {
const viewer = document.createElement("model-viewer");
viewer.style.position = "absolute";
viewer.style.width = "100%";
viewer.style.height = "100%";
viewer.src = this.app.vault.getResourcePath(file);
this.contentEl.appendChild(viewer);
const settings = this.plugin.settings;
if (settings.cameraControls) viewer.cameraControls = true;
if (settings.disablePan) viewer.disablePan = true;
if (settings.disableZoom) viewer.disableZoom = true;
if (settings.autoRotate) viewer.autoRotate = true;
if (!settings.interactionPrompt) viewer.interactionPrompt = "none";
}
async onUnloadFile(file: TFile): Promise<void> {}
}
class ModelViewerSettingTab extends PluginSettingTab {
plugin: ModelViewerPlugin;
@ -44,15 +178,61 @@ class ModelViewerSettingTab extends PluginSettingTab {
containerEl.empty();
new Setting(containerEl)
.setName("Setting #1")
.setDesc("It's a secret")
.addText((text) =>
text
.setPlaceholder("Enter your secret")
.setValue(this.plugin.settings.mySetting)
.onChange(async (value) => {
this.plugin.settings.mySetting = value;
.setName("Camera Controls")
.setDesc("Enable camera controls for the model")
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.cameraControls).onChange(async (value) => {
this.plugin.settings.cameraControls = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Disable Pan")
.setDesc("Disable panning the model")
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.disablePan).onChange(async (value) => {
this.plugin.settings.disablePan = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Disable Zoom")
.setDesc("Disable zooming the model")
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.disableZoom).onChange(async (value) => {
this.plugin.settings.disableZoom = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Auto Rotate")
.setDesc("Enable auto rotation of the model")
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.autoRotate).onChange(async (value) => {
this.plugin.settings.autoRotate = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Interaction Prompt")
.setDesc("Show prompt to interact with the model")
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.interactionPrompt).onChange(async (value) => {
this.plugin.settings.interactionPrompt = value;
await this.plugin.saveSettings();
})
);
new Setting(containerEl)
.setName("Reset Settings")
.setDesc("Reset settings to default")
.addButton((button) =>
button
.setButtonText("Reset to default")
.setWarning()
.onClick(async () => {
this.plugin.settings = DEFAULT_SETTINGS;
await this.plugin.saveSettings();
this.display();
})
);
}

121
package-lock.json generated
View file

@ -8,6 +8,9 @@
"name": "obsidian-model-viewer",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@google/model-viewer": "^4.0.0"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
@ -484,6 +487,22 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@google/model-viewer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-4.0.0.tgz",
"integrity": "sha512-hf4mixP178gGtvWQq97a0uULr7k98BwJGRU3lvt12XnurOdLmZA/RPdW4tPsINNNQxZvmvQsjud4lZWre6WGNw==",
"license": "Apache-2.0",
"dependencies": {
"@monogrid/gainmap-js": "^3.0.1",
"lit": "^2.7.2"
},
"engines": {
"node": ">=6.0.0"
},
"peerDependencies": {
"three": "^0.169.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -525,6 +544,21 @@
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.6.3.tgz",
"integrity": "sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.0.0"
}
},
"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",
@ -533,6 +567,18 @@
"license": "MIT",
"peer": true
},
"node_modules/@monogrid/gainmap-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.1.0.tgz",
"integrity": "sha512-Obb0/gEd/HReTlg8ttaYk+0m62gQJmCblMOjHSMHRrBP2zdfKMHLCRbh/6ex9fSUJMKdjjIEiohwkbGD3wj2Nw==",
"license": "MIT",
"dependencies": {
"promise-worker-transferable": "^1.0.4"
},
"peerDependencies": {
"three": ">= 0.159.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -612,6 +658,12 @@
"@types/estree": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.29.0.tgz",
@ -1610,6 +1662,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -1704,6 +1762,12 @@
"node": ">=8"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -1776,6 +1840,46 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lit": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit/-/lit-2.8.0.tgz",
"integrity": "sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^1.6.0",
"lit-element": "^3.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/lit-element": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.3.3.tgz",
"integrity": "sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.1.0",
"@lit/reactive-element": "^1.3.0",
"lit-html": "^2.8.0"
}
},
"node_modules/lit-html": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.8.0.tgz",
"integrity": "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -2024,6 +2128,16 @@
"node": ">= 0.8.0"
}
},
"node_modules/promise-worker-transferable": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
"license": "Apache-2.0",
"dependencies": {
"is-promise": "^2.1.0",
"lie": "^3.0.2"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -2239,6 +2353,13 @@
"license": "MIT",
"peer": true
},
"node_modules/three": {
"version": "0.169.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz",
"integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==",
"license": "MIT",
"peer": true
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View file

@ -20,5 +20,8 @@
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
},
"dependencies": {
"@google/model-viewer": "^4.0.0"
}
}

BIN
screenshot.avif Normal file

Binary file not shown.

View file

@ -0,0 +1,18 @@
:is(.markdown-preview-view, .markdown-source-view) .model-viewer-embed {
display: inline-block;
border-radius: var(--radius-m);
background-color: var(--background-primary-alt);
}
.model-viewer-embed model-viewer {
aspect-ratio: 1;
width: 100%;
height: 100%;
max-width: 100%;
min-height: 150px;
max-height: 450px;
}
.popover .model-viewer-embed model-viewer {
width: 300px;
}