diff --git a/README.md b/README.md index fe49be1..0ced632 100644 --- a/README.md +++ b/README.md @@ -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 Google’s [\](https://modelviewer.dev/) component. + +![Screenshot](screenshot.avif) + +> \ 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. + +[\ FAQ](https://modelviewer.dev/docs/faq.html) + +## Features + +==TODO== diff --git a/main.ts b/main.ts index 3c3fefa..128b388 100644 --- a/main.ts +++ b/main.ts @@ -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 { + 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 {} +} + 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(); }) ); } diff --git a/package-lock.json b/package-lock.json index 1f8030d..e428210 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c04b463..ab9d541 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,8 @@ "obsidian": "latest", "tslib": "2.4.0", "typescript": "4.7.4" + }, + "dependencies": { + "@google/model-viewer": "^4.0.0" } } diff --git a/screenshot.avif b/screenshot.avif new file mode 100644 index 0000000..e449e17 Binary files /dev/null and b/screenshot.avif differ diff --git a/styles.css b/styles.css index e69de29..21b178b 100644 --- a/styles.css +++ b/styles.css @@ -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; +}