├── versions.json ├── src ├── styles.css ├── @types │ └── index.d.ts ├── main.css └── main.ts ├── .release-please-manifest.json ├── svelte.config.js ├── .gitignore ├── manifest.json ├── tsconfig.json ├── release-please-config.json ├── LICENSE ├── package.json ├── README.md ├── esbuild.config.mjs ├── webpack.config.js ├── CHANGELOG.md └── .github └── workflows └── release-please.yml /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import "./main.css"; 2 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "1.2.0" 3 | } 4 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | const sveltePreprocess = require("svelte-preprocess"); 2 | 3 | module.exports = { 4 | preprocess: sveltePreprocess(), 5 | }; 6 | 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | 8 | # build 9 | main.js 10 | *.js.map 11 | dist 12 | styles.css 13 | !src/styles.css 14 | /main.css 15 | 16 | # obsidian 17 | data.json 18 | .DS_Store 19 | rollup.config-dev.js 20 | webpack.dev.js 21 | main.js.LICENSE.txt 22 | 23 | .env 24 | .vscode -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "image-window", 3 | "name": "Second Window", 4 | "version": "1.2.0", 5 | "minAppVersion": "0.12.0", 6 | "author": "Jeremy Valentine", 7 | "description": "Allow images & notes to be viewed in new Obsidian windows.", 8 | "authorUrl": "", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "ESNext", 5 | "target": "esnext", 6 | "allowJs": true, 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "importHelpers": true, 10 | "types": ["node"], 11 | "lib": ["dom", "esnext", "scripthost", "es2015"], 12 | "allowSyntheticDefaultImports": true 13 | }, 14 | "include": ["src/**/*"] 15 | } 16 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "changelog-path": "CHANGELOG.md", 5 | "release-type": "node" 6 | } 7 | }, 8 | "include-component-in-tag": false, 9 | "include-v-in-tag": false, 10 | "extra-files": [ 11 | { 12 | "type": "json", 13 | "path": "manifest.json", 14 | "jsonpath": "$.version" 15 | } 16 | ], 17 | "last-release-sha": "10fa0732c6c862e1927332af083ded127f88255c", 18 | 19 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 20 | } 21 | -------------------------------------------------------------------------------- /src/@types/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface PluginSettings { 2 | saveWindowLocations: boolean; 3 | useCustomWindowName: boolean; 4 | customWindowName: string; 5 | /** 6 | * indexed by configured window name, plus the default window at "DEFAULT" 7 | */ 8 | windows: Record; 9 | } 10 | 11 | export interface WindowSettings { 12 | /** 13 | * not serialized 14 | */ 15 | id?: number; 16 | 17 | /** 18 | * indexed by host name 19 | */ 20 | hosts: Record; 21 | } 22 | 23 | export interface WindowState { 24 | width: number; 25 | height: number; 26 | x: number; 27 | y: number; 28 | maximized: boolean; 29 | fullscreen: boolean; 30 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 valentine195 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-image-window", 3 | "version": "1.2.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node ./esbuild.config.mjs", 8 | "build": "node ./esbuild.config.mjs production", 9 | "build:wp": "webpack", 10 | "dev:wp": "webpack --config webpack.dev.js -w", 11 | "test": "jest", 12 | "release": "standard-version" 13 | }, 14 | "standard-version": { 15 | "t": "" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "@babel/core": "^7.15.8", 22 | "@babel/preset-env": "^7.15.8", 23 | "@types/image-to-base64": "^2.1.0", 24 | "@types/mime": "^2.0.3", 25 | "@types/node": "^14.17.34", 26 | "babel-loader": "^8.2.2", 27 | "base64-arraybuffer": "^1.0.1", 28 | "builtin-modules": "^3.2.0", 29 | "copy-webpack-plugin": "^9.0.1", 30 | "css-loader": "^5.2.7", 31 | "dotenv": "^16.0.0", 32 | "electron": "^24.8.5", 33 | "esbuild": "^0.14.3", 34 | "mime": "^3.0.0", 35 | "mini-css-extract-plugin": "^2.3.0", 36 | "obsidian": "^1.4.11", 37 | "random-word-slugs": "^0.1.6", 38 | "standard-version": "^9.3.2", 39 | "svelte-preprocess": "^4.9.8", 40 | "ts-loader": "^9.2.3", 41 | "tslib": "^2.3.1", 42 | "typescript": "^4.2.4", 43 | "webpack": "^5.41.1", 44 | "webpack-cli": "^4.7.2", 45 | "webpack-node-externals": "^3.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | .second-window-settings .additional-container > .additional > .setting-item { 2 | border-top: 0; 3 | padding-top: 9px; 4 | } 5 | .second-window-settings 6 | .additional-container 7 | > .additional 8 | > .setting-item 9 | > .setting-item-control 10 | > *:first-child { 11 | margin: 0 6px; 12 | } 13 | .second-window-settings .additional-container .additional { 14 | margin: 6px 12px; 15 | } 16 | 17 | .second-window-settings details > summary { 18 | outline: none; 19 | display: block !important; 20 | list-style: none !important; 21 | list-style-type: none !important; 22 | min-height: 1rem; 23 | border-top-left-radius: 0.1rem; 24 | border-top-right-radius: 0.1rem; 25 | cursor: pointer; 26 | position: relative; 27 | } 28 | 29 | .second-window-settings details > summary::-webkit-details-marker, 30 | .second-window-settings details > summary::marker { 31 | display: none !important; 32 | } 33 | 34 | .second-window-settings details > summary > .collapser { 35 | position: absolute; 36 | top: 50%; 37 | right: 8px; 38 | transform: translateY(-50%); 39 | content: ""; 40 | } 41 | 42 | .second-window-settings details > summary > .collapser > .handle { 43 | transform: rotate(0deg); 44 | transition: transform 0.25s; 45 | background-color: currentColor; 46 | -webkit-mask-repeat: no-repeat; 47 | mask-repeat: no-repeat; 48 | -webkit-mask-size: contain; 49 | mask-size: contain; 50 | -webkit-mask-image: var(--admonition-details-icon); 51 | mask-image: var(--admonition-details-icon); 52 | width: 20px; 53 | height: 20px; 54 | } 55 | 56 | .second-window-settings details[open] > summary > .collapser > .handle { 57 | transform: rotate(90deg); 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > 🥇 Our documentation has moved ***[here](https://plugins.javalent.com/second-window)***. 2 | > 3 | > Buy Me a Coffee at ko-fi.com 4 | > 5 | > --- 6 | > 7 | > **Development Status**: Maintenance Mode 8 | > 9 | > Due to a glut of high priority Javalent plugin projects, this plugin is now entering maintenance mode for the time being. This is **not** a permanent status. 10 | > - PR's will be reviewed. 11 | > - *Yay* bugs will be reviewed and worked if able. 12 | > - Feature Requests **will not** be worked. 13 | 14 | Also known as **Image Window**, this plugin allows you to open a note or image file in your vault in a new pop-up Obsidian window. 15 | 16 | ## Quickstart 17 | 18 | Notes and images can be opened in this way by right-clicking on them or their links, and selecting "**Open in New window**." 19 | 20 | Alternatively, they can be opened if the Core Plugin [Command Palette](https://help.obsidian.md/Plugins/Command+palette "Obsidian") is enabled, using the "**Open Image in New Window**" command. 21 | 22 | ## Why Use the Second Window Plugin? 23 | 24 | The Second Window plugin is an enhancement to Obsidian's New Window functionality that adds features missing from the default behaviour. Here are a few examples: 25 | 26 | - When you request to "Open an Image in the Second Window," the plugin will send it to the existing Second Window that is already open. This way, you can set up the second window on a player-facing screen and leave it there. 27 | - The plugin stretches the images to the full size of the window, while Obsidian's "New Window" opens them in the default size. This allows you to view images more comfortably without having to adjust the zoom or window size manually. 28 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | import { config } from "dotenv"; 5 | 6 | config(); 7 | 8 | const banner = `/* 9 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 10 | if you want to view the source, please visit the github repository of this plugin 11 | */ 12 | `; 13 | 14 | const prod = process.argv[2] === "production"; 15 | 16 | const dir = prod ? "./" : process.env.OUTDIR ?? "./"; 17 | 18 | esbuild 19 | .build({ 20 | banner: { 21 | js: banner 22 | }, 23 | entryPoints: ["src/main.ts", "src/styles.css"], 24 | bundle: true, 25 | external: [ 26 | "obsidian", 27 | "electron", 28 | "codemirror", 29 | "@codemirror/autocomplete", 30 | "@codemirror/closebrackets", 31 | "@codemirror/collab", 32 | "@codemirror/commands", 33 | "@codemirror/comment", 34 | "@codemirror/fold", 35 | "@codemirror/gutter", 36 | "@codemirror/highlight", 37 | "@codemirror/history", 38 | "@codemirror/language", 39 | "@codemirror/lint", 40 | "@codemirror/matchbrackets", 41 | "@codemirror/panel", 42 | "@codemirror/rangeset", 43 | "@codemirror/rectangular-selection", 44 | "@codemirror/search", 45 | "@codemirror/state", 46 | "@codemirror/stream-parser", 47 | "@codemirror/text", 48 | "@codemirror/tooltip", 49 | "@codemirror/view", 50 | ...builtins 51 | ], 52 | format: "cjs", 53 | watch: !prod, 54 | minify: prod, 55 | target: "es2020", 56 | logLevel: "info", 57 | sourcemap: !prod ? "inline" : false, 58 | treeShaking: true, 59 | outdir: dir 60 | }) 61 | .catch(() => process.exit(1)); 62 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 4 | const sveltePreprocess = require("svelte-preprocess"); 5 | 6 | const isDevMode = process.env.NODE_ENV === "development"; 7 | 8 | module.exports = { 9 | entry: "./src/main.ts", 10 | output: { 11 | path: path.resolve(__dirname, "."), 12 | filename: "main.js", 13 | libraryTarget: "commonjs" 14 | }, 15 | target: "node", 16 | mode: isDevMode ? "development" : "production", 17 | ...(isDevMode ? { devtool: "eval" } : {}), 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.tsx?$/, 22 | loader: "ts-loader", 23 | options: { 24 | transpileOnly: true 25 | } 26 | }, 27 | { 28 | test: /\.(svelte)$/, 29 | use: [ 30 | { loader: "babel-loader" }, 31 | { 32 | loader: "svelte-loader", 33 | options: { 34 | preprocess: sveltePreprocess({}) 35 | } 36 | } 37 | ] 38 | }, 39 | { 40 | test: /\.css?$/, 41 | use: [ 42 | MiniCssExtractPlugin.loader, 43 | { 44 | loader: "css-loader", 45 | options: { 46 | url: false 47 | } 48 | } 49 | ] 50 | }, 51 | { 52 | test: /\.(svg|njk|html)$/, 53 | type: "asset/source" 54 | } 55 | ] 56 | }, 57 | plugins: [ 58 | new CopyPlugin({ 59 | patterns: [ 60 | { from: "./manifest.json", to: "." }, 61 | { from: "./src/main.css", to: "./styles.css" } 62 | ] 63 | }), 64 | 65 | new MiniCssExtractPlugin({ 66 | filename: "styles.css" 67 | }) 68 | ], 69 | resolve: { 70 | alias: { 71 | svelte: path.resolve("node_modules", "svelte"), 72 | "~": path.resolve(__dirname, "src"), 73 | src: path.resolve(__dirname, "src") 74 | }, 75 | extensions: [".ts", ".tsx", ".js", ".svelte"], 76 | mainFields: ["svelte", "browser", "module", "main"] 77 | }, 78 | externals: { 79 | electron: "commonjs2 electron", 80 | obsidian: "commonjs2 obsidian" 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ## [1.2.0](https://github.com/javalent/second-window/compare/1.1.0...1.2.0) (2024-01-03) 6 | 7 | 8 | ### Features 9 | 10 | * add custom window name setting ([#35](https://github.com/javalent/second-window/issues/35)) ([01ca9bc](https://github.com/javalent/second-window/commit/01ca9bc52e065b23ea261d7f3a98bf60c14bc25a)) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * Fixes opening windows failing to load (close [#40](https://github.com/javalent/second-window/issues/40)) ([75abc4c](https://github.com/javalent/second-window/commit/75abc4c1cdde64e239f8840aa756b4d673377343)) 16 | 17 | ## [1.1.0](https://github.com/javalent/second-window/compare/1.0.8...1.1.0) (2023-08-27) 18 | 19 | 20 | ### Features 21 | 22 | * Adds a context menu in reading mode & live preview ([eefada6](https://github.com/javalent/second-window/commit/eefada6509b9dc3e7343ebd92b492ee9e2758ca4)) 23 | 24 | ## [1.0.8](https://github.com/javalent/second-window/compare/1.0.7...1.0.8) (2023-08-13) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * reduce dom for images ([f0d6c36](https://github.com/javalent/second-window/commit/f0d6c36f979041b33312d4368946443023d2eeb6)) 30 | 31 | ## [1.0.7](https://github.com/javalent/second-window/compare/1.0.6...1.0.7) (2023-08-13) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * Enforce UTF8 Encoding in second window ([b16d501](https://github.com/javalent/second-window/commit/b16d501b8d1653d498dfe012dd38c5eb68be0611)) 37 | * inherit css correctly ([56239c0](https://github.com/javalent/second-window/commit/56239c00cdb5e9b735b4b0ac8582eba317d1c950)) 38 | 39 | ### [1.0.6](https://github.com/valentine195/obsidian-image-window/compare/1.0.5...1.0.6) (2023-03-04) 40 | 41 | 42 | ### Bug Fixes 43 | 44 | * improves settings styling ([bc5c8e1](https://github.com/valentine195/obsidian-image-window/commit/bc5c8e1282cc1845a36d5e6c039af3bd35e3e030)) 45 | 46 | ### [1.0.5](https://github.com/valentine195/obsidian-image-window/compare/1.0.4...1.0.5) (2022-03-21) 47 | 48 | 49 | ### Bug Fixes 50 | 51 | * Update name to Second Window ([fab5a12](https://github.com/valentine195/obsidian-image-window/commit/fab5a129e456c73da3d8547ec1ecfeeb8e5bbd5f)) 52 | 53 | ### [1.0.4](https://github.com/valentine195/obsidian-image-window/compare/2.0.6...1.0.4) (2022-03-03) 54 | 55 | 56 | ### Bug Fixes 57 | 58 | * Update esbuild to fix issues with loading on older Obsidian releases (close [#3](https://github.com/valentine195/obsidian-image-window/issues/3)) ([347cf01](https://github.com/valentine195/obsidian-image-window/commit/347cf012bf0bde4f4595263ceb69bed6f60703b3)) 59 | 60 | ### [1.0.3](https://github.com/valentine195/obsidian-image-window/compare/1.0.2...1.0.3) (2021-12-07) 61 | 62 | ### [1.0.2](https://github.com/valentine195/obsidian-image-window/compare/1.0.1...1.0.2) (2021-12-05) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * fixed issue with opening image on mac ([6fe3f2d](https://github.com/valentine195/obsidian-image-window/commit/6fe3f2d6cdde956b8fe518bf55ce345465b652c0)) 68 | 69 | ### [1.0.1](https://github.com/valentine195/obsidian-image-window/compare/1.0.0...1.0.1) (2021-12-01) 70 | 71 | 72 | ### Bug Fixes 73 | 74 | * moved remote to generic import ([eb430cf](https://github.com/valentine195/obsidian-image-window/commit/eb430cf8bd8076621ce3d107336a36e2ce7a07e9)) 75 | 76 | ## [1.0.0](https://github.com/valentine195/obsidian-image-window/compare/0.0.2...1.0.0) (2021-12-01) 77 | 78 | 79 | ### Features 80 | 81 | * initial release ([a4efca3](https://github.com/valentine195/obsidian-image-window/commit/a4efca3475c6973d0a2ca5a06b2c5b8f2e3f9d7f)) 82 | 83 | ### 0.0.2 (2021-12-01) 84 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | 4 | push: 5 | branches: 6 | - "main" 7 | env: 8 | PLUGIN_NAME: second-window 9 | 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | 14 | name: release-please 15 | 16 | jobs: 17 | release-please: 18 | runs-on: ubuntu-latest 19 | outputs: 20 | release_created: ${{ steps.release.outputs.release_created }} 21 | upload_url: ${{ steps.release.outputs.upload_url }} 22 | tag_name: ${{ steps.release.outputs.tag_name }} 23 | steps: 24 | - uses: google-github-actions/release-please-action@v3 25 | id: release 26 | with: 27 | command: manifest 28 | 29 | upload-build: 30 | runs-on: ubuntu-latest 31 | needs: release-please 32 | if: ${{ needs.release-please.outputs.release_created }} 33 | env: 34 | upload_url: ${{ needs.release-please.outputs.upload_url }} 35 | tag_name: ${{ needs.release-please.outputs.tag_name }} 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Use Node.js 39 | uses: actions/setup-node@v3 40 | with: 41 | node-version: "18.x" # You might need to adjust this value to your own version 42 | - name: Build 43 | id: build 44 | run: | 45 | npm install 46 | npm run build --if-present 47 | mkdir ${{ env.PLUGIN_NAME }} 48 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 49 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 50 | - name: Upload zip file 51 | id: upload-zip 52 | uses: actions/upload-release-asset@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | upload_url: ${{ env.upload_url }} 57 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 58 | asset_name: ${{ env.PLUGIN_NAME }}-${{ env.tag_name }}.zip 59 | asset_content_type: application/zip 60 | - name: Upload main.js 61 | id: upload-main 62 | uses: actions/upload-release-asset@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | upload_url: ${{ env.upload_url }} 67 | asset_path: ./main.js 68 | asset_name: main.js 69 | asset_content_type: text/javascript 70 | - name: Upload manifest.json 71 | id: upload-manifest 72 | uses: actions/upload-release-asset@v1 73 | env: 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | with: 76 | upload_url: ${{ env.upload_url }} 77 | asset_path: ./manifest.json 78 | asset_name: manifest.json 79 | asset_content_type: application/json 80 | - name: Upload styles.css 81 | id: upload-css 82 | uses: actions/upload-release-asset@v1 83 | env: 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | with: 86 | upload_url: ${{ env.upload_url }} 87 | asset_path: ./styles.css 88 | asset_name: styles.css 89 | asset_content_type: text/css 90 | notify: 91 | needs: upload-build 92 | uses: javalent/workflows/.github/workflows/notify.yml@main 93 | secrets: inherit 94 | with: 95 | name: Second Window 96 | repo: second-window 97 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "./main.css"; 2 | 3 | import { 4 | App, 5 | Component, 6 | debounce, 7 | FileSystemAdapter, 8 | FuzzySuggestModal, 9 | Menu, 10 | Plugin, 11 | PluginSettingTab, 12 | Setting, 13 | TextComponent, 14 | TFile, 15 | WorkspaceLeaf, 16 | WorkspaceWindow 17 | } from "obsidian"; 18 | import { PluginSettings, WindowState } from "./@types"; 19 | import type { BrowserWindow } from "electron"; 20 | 21 | import { getType } from "mime/lite"; 22 | import { generateSlug } from "random-word-slugs"; 23 | 24 | import * as os from "os"; 25 | 26 | const DEFAULT_WINDOW_NAME = "Second Window"; 27 | 28 | const DEFAULT_SETTINGS: PluginSettings = { 29 | saveWindowLocations: true, 30 | useCustomWindowName: false, 31 | customWindowName: DEFAULT_WINDOW_NAME, 32 | windows: {} 33 | }; 34 | 35 | declare global { 36 | interface Window { 37 | DOMPurify: { 38 | sanitize( 39 | html: string, 40 | options: Record 41 | ): DocumentFragment; 42 | }; 43 | electronWindow: BrowserWindow; 44 | } 45 | } 46 | 47 | declare module "obsidian" { 48 | interface App { 49 | customCss: { 50 | extraStyleEls: HTMLStyleElement[]; 51 | enabledSnippets: string[]; 52 | getSnippetsFolder(): string; 53 | getThemeFolder(): string; 54 | theme: string; 55 | }; 56 | plugins: { 57 | plugins: Record; 58 | getPluginFolder(): string; 59 | }; 60 | } 61 | interface Vault { 62 | config: { 63 | theme?: "obsidian" | "moonstone"; 64 | }; 65 | resolveFileUrl(path: string): TFile; 66 | } 67 | interface Plugin { 68 | _loaded: boolean; 69 | } 70 | interface View { 71 | headerEl: HTMLDivElement; 72 | contentEl: HTMLDivElement; 73 | } 74 | interface WorkspaceLeaf { 75 | parent: WorkspaceTabs; 76 | } 77 | interface WorkspaceTabs { 78 | tabHeaderContainerEl: HTMLDivElement; 79 | } 80 | interface WorkspaceWindow { 81 | rootEl: HTMLDivElement; 82 | } 83 | } 84 | 85 | interface Parent { 86 | app: App; 87 | settings: PluginSettings; 88 | saveSettings(): Promise; 89 | } 90 | 91 | let uniqueId = 0; 92 | 93 | class NamedWindow extends Component { 94 | window: WorkspaceWindow; 95 | stale = false; 96 | leaf: WorkspaceLeaf; 97 | 98 | constructor(private parent: Parent, private name: string) { 99 | super(); 100 | this.load(); 101 | } 102 | 103 | rename(name: string) { 104 | this.name = name; 105 | } 106 | 107 | adjust(leaf: WorkspaceLeaf, image: boolean) { 108 | let parent = leaf.parent; 109 | parent.tabHeaderContainerEl.empty(); 110 | this.leaf.view.headerEl.empty(); 111 | 112 | if (image) { 113 | this.leaf.view.contentEl 114 | .querySelector("img") 115 | ?.setAttr( 116 | "style", 117 | "height: 100%; width: 100%; object-fit: contain" 118 | ); 119 | } 120 | } 121 | 122 | async loadFile(file: TFile) { 123 | if (!(this.parent.app.vault.adapter instanceof FileSystemAdapter)) 124 | return; 125 | if (!this.window) { 126 | const state: WindowState | undefined = 127 | this.parent.settings.windows[this.name]?.hosts?.[os.hostname()]; 128 | 129 | this.leaf = this.parent.app.workspace.openPopoutLeaf(); 130 | this.window = this.leaf.getContainer() as WorkspaceWindow; 131 | 132 | if (state) { 133 | this.window.win.electronWindow.setBounds(state); 134 | this.window.win.electronWindow.setFullScreen(state.fullscreen); 135 | if (state.maximized) this.window.win.electronWindow.maximize(); 136 | } 137 | this.window.win.electronWindow.on("close", () => { 138 | this.window = null; 139 | }); 140 | this.registerEvent( 141 | this.parent.app.workspace.on("window-close", (win, window) => { 142 | if (win == this.window) { 143 | this.window = null; 144 | } 145 | }) 146 | ); 147 | 148 | const positionHandler = debounce( 149 | this.onMoved.bind(this), 150 | 500, 151 | true 152 | ); 153 | this.window.win.electronWindow.on("move", positionHandler); 154 | 155 | // resize is fired when the window is restored from maximized, and we need to know 156 | this.window.win.electronWindow.on("resize", positionHandler); 157 | this.window.win.electronWindow.on( 158 | "enter-full-screen", 159 | positionHandler 160 | ); 161 | this.window.win.electronWindow.on( 162 | "leave-full-screen", 163 | positionHandler 164 | ); 165 | } 166 | 167 | await this.leaf.openFile(file, { state: { mode: "preview" } }); 168 | 169 | this.window.rootEl.querySelector(".status-bar")?.detach(); 170 | this.adjust(this.leaf, /image/.test(getType(file.extension))); 171 | if (this.parent.settings.useCustomWindowName) { 172 | this.window.win.electronWindow.setTitle( 173 | this.name !== DEFAULT_WINDOW_NAME 174 | ? this.name 175 | : this.parent.settings.customWindowName 176 | ); 177 | } else { 178 | this.window.win.electronWindow.setTitle(file.name); 179 | } 180 | } 181 | /** 182 | * Save window position and size under a key specific to the host. This way, 183 | * sharing the vault with a second computer with a different monitor layout will not overwrite the 184 | * first computer's saved state. 185 | * @param name 186 | */ 187 | async onMoved() { 188 | if (!this.parent.settings.saveWindowLocations) return; 189 | const position = this.window.win.electronWindow.getPosition(); 190 | const size = this.window.win.electronWindow.getSize(); 191 | const hostname = os.hostname(); 192 | if (!this.parent.settings.windows[this.name]) 193 | this.parent.settings.windows[this.name] = { hosts: {} }; 194 | this.parent.settings.windows[this.name].hosts[hostname] = { 195 | x: position[0], 196 | y: position[1], 197 | width: size[0], 198 | height: size[1], 199 | fullscreen: this.window.win.electronWindow.isFullScreen(), 200 | maximized: this.window.win.electronWindow.isMaximized() 201 | }; 202 | // REVISIT don't invalidate views for this save, even if we end up having a settings dirty flag 203 | await this.parent.saveSettings(); 204 | } 205 | 206 | onunload() { 207 | if (this.window) { 208 | this.window.win.electronWindow.close(); 209 | } 210 | console.log("Second Window unloaded."); 211 | } 212 | } 213 | 214 | class ImageWindowSettingTab extends PluginSettingTab { 215 | constructor(private plugin: Plugin, private parent: Parent) { 216 | super(parent.app, plugin); 217 | } 218 | 219 | display() { 220 | const { containerEl } = this; 221 | 222 | containerEl.empty(); 223 | containerEl.addClass("second-window-settings"); 224 | containerEl.createEl("h2", { 225 | text: "Settings for Second Window Plugin" 226 | }); 227 | 228 | new Setting(containerEl) 229 | .setName("Save Window Locations") 230 | .setDesc( 231 | "If true, window locations are saved in the plugin settings. Each computer with a different hostname has its own copy of these saved locations, so that window layouts can differ." 232 | ) 233 | .addToggle((toggle) => 234 | toggle 235 | .setValue(this.parent.settings.saveWindowLocations) 236 | .onChange(async (value) => { 237 | this.parent.settings.saveWindowLocations = value; 238 | if (!value) { 239 | for (const window of Object.values( 240 | this.parent.settings.windows 241 | )) { 242 | // flush all saved locations 243 | window.hosts = {}; 244 | } 245 | } 246 | await this.parent.saveSettings(); 247 | }) 248 | ); 249 | 250 | new Setting(containerEl) 251 | .setName("Use Custom Window Name") 252 | .setDesc( 253 | "If true, use a custom window name instead of the file name. Set as window's name when using named windows." 254 | ) 255 | .addToggle((toggle) => 256 | toggle 257 | .setValue(this.parent.settings.useCustomWindowName) 258 | .onChange(async (value) => { 259 | this.parent.settings.useCustomWindowName = value; 260 | await this.parent.saveSettings(); 261 | this.display(); 262 | }) 263 | ); 264 | 265 | if (this.parent.settings.useCustomWindowName) { 266 | new Setting(containerEl) 267 | .setName("Custom Window Name") 268 | .setDesc( 269 | "The custom window name to show when not using named windows." 270 | ) 271 | .addText((text) => 272 | text 273 | .setValue(this.parent.settings.customWindowName) 274 | .onChange(async (value) => { 275 | this.parent.settings.customWindowName = value; 276 | await this.parent.saveSettings(); 277 | }) 278 | ); 279 | } 280 | 281 | this.buildWindows(this.containerEl.createDiv()); 282 | } 283 | buildWindows(el: HTMLElement) { 284 | const additionalContainer = el.createDiv("additional-container"); 285 | new Setting(additionalContainer) 286 | .setName("Add New Named Window") 287 | .setDesc( 288 | "Name windows allow you to specify specific windows to open files in." 289 | ) 290 | .addButton((button) => 291 | button 292 | .setIcon("plus") 293 | .setTooltip("Add a new window") 294 | .onClick(async () => { 295 | let name = generateSlug(2); 296 | while ( 297 | this.parent.settings.windows.hasOwnProperty(name) 298 | ) { 299 | name = generateSlug(2); 300 | } 301 | this.parent.settings.windows[name] = { 302 | hosts: {} 303 | }; 304 | await this.parent.saveSettings(); 305 | this.display(); 306 | }) 307 | ); 308 | const additional = additionalContainer.createDiv("additional"); 309 | for (const initialName of Object.keys(this.parent.settings.windows)) { 310 | if (initialName === DEFAULT_WINDOW_NAME) continue; 311 | const state = { collision: false, name: initialName }; 312 | const setting = new Setting(additional).addExtraButton((button) => 313 | button 314 | .setIcon("trash") 315 | .setTooltip("Delete this window") 316 | .onClick(async () => { 317 | delete this.parent.settings.windows[state.name]; 318 | additional.removeChild(setting.settingEl); 319 | await this.parent.saveSettings(); 320 | }) 321 | ); 322 | const text = new TextComponent(setting.nameEl) 323 | .setValue(initialName) 324 | .onChange(async (value) => { 325 | if (value === state.name) return; 326 | if ( 327 | value === DEFAULT_WINDOW_NAME || 328 | this.parent.settings.windows[value] !== undefined 329 | ) { 330 | // collision can't be allowed, TODO how do we validate red? 331 | text.inputEl.addClass("is-invalid"); 332 | state.collision = true; 333 | return; 334 | } 335 | text.inputEl.removeClass("is-invalid"); 336 | const record = this.parent.settings.windows[state.name]; 337 | if (record !== undefined) { 338 | this.parent.settings.windows[value] = record; 339 | delete this.parent.settings.windows[state.name]; 340 | } else { 341 | this.parent.settings.windows[value] = { hosts: {} }; 342 | } 343 | state.name = value; 344 | await this.parent.saveSettings(); 345 | }); 346 | text.inputEl.on("focusin", "input", () => { 347 | text.inputEl.select(); 348 | }); 349 | text.inputEl.on("focusout", "input", () => { 350 | if (state.collision) { 351 | state.collision = false; 352 | this.display(); 353 | } 354 | }); 355 | } 356 | } 357 | } 358 | 359 | export default class ImageWindow extends Plugin { 360 | settings: PluginSettings; 361 | /** 362 | * Default window, available even if nothing configured. 363 | */ 364 | defaultWindow: NamedWindow = new NamedWindow(this, DEFAULT_WINDOW_NAME); 365 | /** 366 | * Optional, additional windows created by settings. 367 | */ 368 | windows: Map = new Map(); 369 | 370 | get stylesheets() { 371 | return document.head.innerHTML; 372 | } 373 | async onload() { 374 | await this.loadSettings(); 375 | if ("DEFAULT" in this.settings.windows) { 376 | this.settings.windows[DEFAULT_WINDOW_NAME] = { 377 | ...this.settings.windows.DEFAULT 378 | }; 379 | delete this.settings.windows.DEFAULT; 380 | } 381 | this.addSettingTab(new ImageWindowSettingTab(this, this)); 382 | 383 | this.registerEvent( 384 | this.app.workspace.on("file-menu", (menu, file) => { 385 | if (!(this.app.vault.adapter instanceof FileSystemAdapter)) 386 | return; 387 | if (!(file instanceof TFile)) return; 388 | 389 | menu.addItem((item) => { 390 | item.setTitle("Open in second window") 391 | .setIcon("open-elsewhere-glyph") 392 | .onClick(async () => { 393 | this.defaultWindow.loadFile(file); 394 | }); 395 | }); 396 | for (const [name, record] of Object.entries( 397 | this.settings.windows 398 | )) { 399 | if (name === DEFAULT_WINDOW_NAME) continue; 400 | menu.addItem((item) => { 401 | item.setTitle(`Open in second window '${name}'`) 402 | .setIcon("open-elsewhere-glyph") 403 | .onClick(async () => { 404 | const namedWindow = this.windows.get(record.id); 405 | if (namedWindow !== undefined) { 406 | namedWindow.loadFile(file); 407 | } 408 | }); 409 | }); 410 | } 411 | }) 412 | ); 413 | 414 | this.registerDomEvent(document, "contextmenu", (event: MouseEvent) => { 415 | const target = event.target as HTMLElement; 416 | if (target.localName !== "img") return; 417 | 418 | const imgPath = (target as HTMLImageElement).currentSrc; 419 | const file = this.app.vault.resolveFileUrl(imgPath); 420 | 421 | if (!(file instanceof TFile)) return; 422 | const menu = new Menu(); 423 | menu.addItem((item) => { 424 | item.setTitle("Open in new window") 425 | .setIcon("open-elsewhere-glyph") 426 | .onClick(async () => { 427 | this.defaultWindow.loadFile(file); 428 | }); 429 | }); 430 | 431 | for (const [name, record] of Object.entries( 432 | this.settings.windows 433 | )) { 434 | if (name === DEFAULT_WINDOW_NAME) continue; 435 | menu.addItem((item) => { 436 | item.setTitle(`Open in window '${name}'`) 437 | .setIcon("open-elsewhere-glyph") 438 | .onClick(async () => { 439 | const namedWindow = this.windows.get(record.id); 440 | if (namedWindow !== undefined) { 441 | namedWindow.loadFile(file); 442 | } 443 | }); 444 | }); 445 | } 446 | 447 | menu.showAtPosition({ x: event.pageX, y: event.pageY }); 448 | }); 449 | 450 | this.addCommand({ 451 | id: "open-image", 452 | name: "Open image in new window", 453 | callback: () => { 454 | const files = this.app.vault 455 | .getFiles() 456 | .filter((file) => /image/.test(getType(file.extension))); 457 | const modal = new Suggester(files, this.app); 458 | modal.onClose = () => { 459 | if (modal.file) { 460 | this.defaultWindow.loadFile(modal.file); 461 | } 462 | }; 463 | modal.open(); 464 | } 465 | }); 466 | 467 | console.log("Second Window loaded."); 468 | } 469 | 470 | async onunload() { 471 | this.defaultWindow.unload(); 472 | for (const window of this.windows.values()) { 473 | window.unload(); 474 | } 475 | } 476 | 477 | async loadSettings() { 478 | this.settings = Object.assign( 479 | {}, 480 | DEFAULT_SETTINGS, 481 | await this.loadData() 482 | ); 483 | for (const [name, record] of Object.entries(this.settings.windows)) { 484 | record.id = uniqueId++; 485 | this.windows.set(record.id, new NamedWindow(this, name)); 486 | } 487 | } 488 | 489 | async saveSettings() { 490 | this.updateWindows(); 491 | 492 | // now serialize without the ids 493 | const temp: PluginSettings = JSON.parse(JSON.stringify(this.settings)); 494 | for (const window of Object.values(temp.windows)) { 495 | delete window.id; 496 | } 497 | await this.saveData(temp); 498 | } 499 | 500 | private updateWindows() { 501 | for (const window of this.windows.values()) { 502 | window.stale = true; 503 | } 504 | 505 | // match up configuration to what we have running 506 | for (const key of Object.keys(this.settings.windows)) { 507 | const configured = this.settings.windows[key]; 508 | const existing = this.windows.get(configured.id); 509 | if (existing !== undefined) { 510 | // matched a window 511 | existing.stale = false; 512 | existing.rename(key); 513 | } else { 514 | // added a window 515 | this.windows.set(configured.id, new NamedWindow(this, key)); 516 | } 517 | } 518 | 519 | // cull 520 | for (const [id, window] of this.windows.entries()) { 521 | if (window.stale) { 522 | window.onunload(); 523 | this.windows.delete(id); 524 | } 525 | } 526 | } 527 | } 528 | 529 | class Suggester extends FuzzySuggestModal { 530 | file: TFile; 531 | constructor(public files: TFile[], app: App) { 532 | super(app); 533 | } 534 | getItemText(item: TFile) { 535 | return item.basename; 536 | } 537 | getItems(): TFile[] { 538 | return this.files; 539 | } 540 | onChooseItem(item: TFile, evt: MouseEvent | KeyboardEvent) { 541 | this.file = item; 542 | this.close(); 543 | } 544 | } 545 | --------------------------------------------------------------------------------