mirror of
https://github.com/daniel-fiuk/simple-map.git
synced 2026-07-22 06:54:24 +00:00
initial comit
This commit is contained in:
parent
c82dc0096e
commit
61fea666d4
22 changed files with 7126 additions and 0 deletions
10
.editorconfig
Normal file
10
.editorconfig
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
tab_width = 4
|
||||
28
.github/workflows/lint.yml
vendored
Normal file
28
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
name: Node.js build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.x, 22.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
- run: npm ci
|
||||
- run: npm run build --if-present
|
||||
- run: npm run lint
|
||||
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
.idea
|
||||
|
||||
# npm
|
||||
node_modules
|
||||
|
||||
# Don't include the compiled main.js file in the repo.
|
||||
# They should be uploaded to GitHub releases instead.
|
||||
main.js
|
||||
|
||||
# Exclude sourcemaps
|
||||
*.map
|
||||
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
0
.hotreload
Normal file
0
.hotreload
Normal file
1
.npmrc
Normal file
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
251
AGENTS.md
Normal file
251
AGENTS.md
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
# Obsidian community plugin
|
||||
|
||||
## Project overview
|
||||
|
||||
- Target: Obsidian Community Plugin (TypeScript → bundled JavaScript).
|
||||
- Entry point: `main.ts` compiled to `main.js` and loaded by Obsidian.
|
||||
- Required release artifacts: `main.js`, `manifest.json`, and optional `styles.css`.
|
||||
|
||||
## Environment & tooling
|
||||
|
||||
- Node.js: use current LTS (Node 18+ recommended).
|
||||
- **Package manager: npm** (required for this sample - `package.json` defines npm scripts and dependencies).
|
||||
- **Bundler: esbuild** (required for this sample - `esbuild.config.mjs` and build scripts depend on it). Alternative bundlers like Rollup or webpack are acceptable for other projects if they bundle all external dependencies into `main.js`.
|
||||
- Types: `obsidian` type definitions.
|
||||
|
||||
**Note**: This sample project has specific technical dependencies on npm and esbuild. If you're creating a plugin from scratch, you can choose different tools, but you'll need to replace the build configuration accordingly.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Dev (watch)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
- To use eslint install eslint from terminal: `npm install -g eslint`
|
||||
- To use eslint to analyze this project use this command: `eslint main.ts`
|
||||
- eslint will then create a report with suggestions for code improvement by file and line number.
|
||||
- If your source code is in a folder, such as `src`, you can use eslint with this command to analyze all files in that folder: `eslint ./src/`
|
||||
|
||||
## File & folder conventions
|
||||
|
||||
- **Organize code into multiple files**: Split functionality across separate modules rather than putting everything in `main.ts`.
|
||||
- Source lives in `src/`. Keep `main.ts` small and focused on plugin lifecycle (loading, unloading, registering commands).
|
||||
- **Example file structure**:
|
||||
```
|
||||
src/
|
||||
main.ts # Plugin entry point, lifecycle management
|
||||
settings.ts # Settings interface and defaults
|
||||
commands/ # Command implementations
|
||||
command1.ts
|
||||
command2.ts
|
||||
ui/ # UI components, modals, views
|
||||
modal.ts
|
||||
view.ts
|
||||
utils/ # Utility functions, helpers
|
||||
helpers.ts
|
||||
constants.ts
|
||||
types.ts # TypeScript interfaces and types
|
||||
```
|
||||
- **Do not commit build artifacts**: Never commit `node_modules/`, `main.js`, or other generated files to version control.
|
||||
- Keep the plugin small. Avoid large dependencies. Prefer browser-compatible packages.
|
||||
- Generated output should be placed at the plugin root or `dist/` depending on your build setup. Release artifacts must end up at the top level of the plugin folder in the vault (`main.js`, `manifest.json`, `styles.css`).
|
||||
|
||||
## Manifest rules (`manifest.json`)
|
||||
|
||||
- Must include (non-exhaustive):
|
||||
- `id` (plugin ID; for local dev it should match the folder name)
|
||||
- `name`
|
||||
- `version` (Semantic Versioning `x.y.z`)
|
||||
- `minAppVersion`
|
||||
- `description`
|
||||
- `isDesktopOnly` (boolean)
|
||||
- Optional: `author`, `authorUrl`, `fundingUrl` (string or map)
|
||||
- Never change `id` after release. Treat it as stable API.
|
||||
- Keep `minAppVersion` accurate when using newer APIs.
|
||||
- Canonical requirements are coded here: https://github.com/obsidianmd/obsidian-releases/blob/master/.github/workflows/validate-plugin-entry.yml
|
||||
|
||||
## Testing
|
||||
|
||||
- Manual install for testing: copy `main.js`, `manifest.json`, `styles.css` (if any) to:
|
||||
```
|
||||
<Vault>/.obsidian/plugins/<plugin-id>/
|
||||
```
|
||||
- Reload Obsidian and enable the plugin in **Settings → Community plugins**.
|
||||
|
||||
## Commands & settings
|
||||
|
||||
- Any user-facing commands should be added via `this.addCommand(...)`.
|
||||
- If the plugin has configuration, provide a settings tab and sensible defaults.
|
||||
- Persist settings using `this.loadData()` / `this.saveData()`.
|
||||
- Use stable command IDs; avoid renaming once released.
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
- Bump `version` in `manifest.json` (SemVer) and update `versions.json` to map plugin version → minimum app version.
|
||||
- Create a GitHub release whose tag exactly matches `manifest.json`'s `version`. Do not use a leading `v`.
|
||||
- Attach `manifest.json`, `main.js`, and `styles.css` (if present) to the release as individual assets.
|
||||
- After the initial release, follow the process to add/update your plugin in the community catalog as required.
|
||||
|
||||
## Security, privacy, and compliance
|
||||
|
||||
Follow Obsidian's **Developer Policies** and **Plugin Guidelines**. In particular:
|
||||
|
||||
- Default to local/offline operation. Only make network requests when essential to the feature.
|
||||
- No hidden telemetry. If you collect optional analytics or call third-party services, require explicit opt-in and document clearly in `README.md` and in settings.
|
||||
- Never execute remote code, fetch and eval scripts, or auto-update plugin code outside of normal releases.
|
||||
- Minimize scope: read/write only what's necessary inside the vault. Do not access files outside the vault.
|
||||
- Clearly disclose any external services used, data sent, and risks.
|
||||
- Respect user privacy. Do not collect vault contents, filenames, or personal information unless absolutely necessary and explicitly consented.
|
||||
- Avoid deceptive patterns, ads, or spammy notifications.
|
||||
- Register and clean up all DOM, app, and interval listeners using the provided `register*` helpers so the plugin unloads safely.
|
||||
|
||||
## UX & copy guidelines (for UI text, commands, settings)
|
||||
|
||||
- Prefer sentence case for headings, buttons, and titles.
|
||||
- Use clear, action-oriented imperatives in step-by-step copy.
|
||||
- Use **bold** to indicate literal UI labels. Prefer "select" for interactions.
|
||||
- Use arrow notation for navigation: **Settings → Community plugins**.
|
||||
- Keep in-app strings short, consistent, and free of jargon.
|
||||
|
||||
## Performance
|
||||
|
||||
- Keep startup light. Defer heavy work until needed.
|
||||
- Avoid long-running tasks during `onload`; use lazy initialization.
|
||||
- Batch disk access and avoid excessive vault scans.
|
||||
- Debounce/throttle expensive operations in response to file system events.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- TypeScript with `"strict": true` preferred.
|
||||
- **Keep `main.ts` minimal**: Focus only on plugin lifecycle (onload, onunload, addCommand calls). Delegate all feature logic to separate modules.
|
||||
- **Split large files**: If any file exceeds ~200-300 lines, consider breaking it into smaller, focused modules.
|
||||
- **Use clear module boundaries**: Each file should have a single, well-defined responsibility.
|
||||
- Bundle everything into `main.js` (no unbundled runtime deps).
|
||||
- Avoid Node/Electron APIs if you want mobile compatibility; set `isDesktopOnly` accordingly.
|
||||
- Prefer `async/await` over promise chains; handle errors gracefully.
|
||||
|
||||
## Mobile
|
||||
|
||||
- Where feasible, test on iOS and Android.
|
||||
- Don't assume desktop-only behavior unless `isDesktopOnly` is `true`.
|
||||
- Avoid large in-memory structures; be mindful of memory and storage constraints.
|
||||
|
||||
## Agent do/don't
|
||||
|
||||
**Do**
|
||||
- Add commands with stable IDs (don't rename once released).
|
||||
- Provide defaults and validation in settings.
|
||||
- Write idempotent code paths so reload/unload doesn't leak listeners or intervals.
|
||||
- Use `this.register*` helpers for everything that needs cleanup.
|
||||
|
||||
**Don't**
|
||||
- Introduce network calls without an obvious user-facing reason and documentation.
|
||||
- Ship features that require cloud services without clear disclosure and explicit opt-in.
|
||||
- Store or transmit vault contents unless essential and consented.
|
||||
|
||||
## Common tasks
|
||||
|
||||
### Organize code across multiple files
|
||||
|
||||
**main.ts** (minimal, lifecycle only):
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { MySettings, DEFAULT_SETTINGS } from "./settings";
|
||||
import { registerCommands } from "./commands";
|
||||
|
||||
export default class MyPlugin extends Plugin {
|
||||
settings: MySettings;
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
registerCommands(this);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**settings.ts**:
|
||||
```ts
|
||||
export interface MySettings {
|
||||
enabled: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: MySettings = {
|
||||
enabled: true,
|
||||
apiKey: "",
|
||||
};
|
||||
```
|
||||
|
||||
**commands/index.ts**:
|
||||
```ts
|
||||
import { Plugin } from "obsidian";
|
||||
import { doSomething } from "./my-command";
|
||||
|
||||
export function registerCommands(plugin: Plugin) {
|
||||
plugin.addCommand({
|
||||
id: "do-something",
|
||||
name: "Do something",
|
||||
callback: () => doSomething(plugin),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Add a command
|
||||
|
||||
```ts
|
||||
this.addCommand({
|
||||
id: "your-command-id",
|
||||
name: "Do the thing",
|
||||
callback: () => this.doTheThing(),
|
||||
});
|
||||
```
|
||||
|
||||
### Persist settings
|
||||
|
||||
```ts
|
||||
interface MySettings { enabled: boolean }
|
||||
const DEFAULT_SETTINGS: MySettings = { enabled: true };
|
||||
|
||||
async onload() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
```
|
||||
|
||||
### Register listeners safely
|
||||
|
||||
```ts
|
||||
this.registerEvent(this.app.workspace.on("file-open", f => { /* ... */ }));
|
||||
this.registerDomEvent(window, "resize", () => { /* ... */ });
|
||||
this.registerInterval(window.setInterval(() => { /* ... */ }, 1000));
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Plugin doesn't load after build: ensure `main.js` and `manifest.json` are at the top level of the plugin folder under `<Vault>/.obsidian/plugins/<plugin-id>/`.
|
||||
- Build issues: if `main.js` is missing, run `npm run build` or `npm run dev` to compile your TypeScript source code.
|
||||
- Commands not appearing: verify `addCommand` runs after `onload` and IDs are unique.
|
||||
- Settings not persisting: ensure `loadData`/`saveData` are awaited and you re-render the UI after changes.
|
||||
- Mobile-only issues: confirm you're not using desktop-only APIs; check `isDesktopOnly` and adjust.
|
||||
|
||||
## References
|
||||
|
||||
- Obsidian sample plugin: https://github.com/obsidianmd/obsidian-sample-plugin
|
||||
- API documentation: https://docs.obsidian.md
|
||||
- Developer policies: https://docs.obsidian.md/Developer+policies
|
||||
- Plugin guidelines: https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines
|
||||
- Style guide: https://help.obsidian.md/style-guide
|
||||
52
esbuild.config.mjs
Normal file
52
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import { builtinModules } from 'node:module';
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtinModules],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
loader: {
|
||||
".svg": "text",
|
||||
}
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
34
eslint.config.mts
Normal file
34
eslint.config.mts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import tseslint from 'typescript-eslint';
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'eslint.config.js',
|
||||
'manifest.json'
|
||||
]
|
||||
},
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
extraFileExtensions: ['.json']
|
||||
},
|
||||
},
|
||||
},
|
||||
...obsidianmd.configs.recommended,
|
||||
globalIgnores([
|
||||
"node_modules",
|
||||
"dist",
|
||||
"esbuild.config.mjs",
|
||||
"eslint.config.js",
|
||||
"version-bump.mjs",
|
||||
"versions.json",
|
||||
"main.js",
|
||||
]),
|
||||
);
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "fantasy-map",
|
||||
"name": "Fantasy Map",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Create and document your fantasy world with a map plugin for Obsidian. Upload your own PNG or SVG maps. Plant pins in important locations and linking them to your notes. A great way to keep track of your world building and storytelling.",
|
||||
"author": "Daniel Fiuk",
|
||||
"authorUrl": "https://danielfiuk.ca",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
5188
package-lock.json
generated
Normal file
5188
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.0.0",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"keywords": [],
|
||||
"license": "0-BSD",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"esbuild": "0.25.5",
|
||||
"eslint-plugin-obsidianmd": "0.1.9",
|
||||
"globals": "14.0.0",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "8.35.1",
|
||||
"@eslint/js": "9.30.1",
|
||||
"jiti": "2.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"obsidian": "latest"
|
||||
}
|
||||
}
|
||||
1
src/assets/mapPinIcon_customizable.svg
Normal file
1
src/assets/mapPinIcon_customizable.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 936 1260" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path transform="translate(0, 40)" d="M467.872,1199.768c0,0 -467.872,-253.071 -467.872,-731.896c0,-258.226 209.646,-467.872 467.872,-467.872c258.226,0 467.872,209.646 467.872,467.872c0,478.824 -467.872,731.896 -467.872,731.896Zm0,-942.391c-116.176,0 -210.496,94.32 -210.496,210.496c0,116.176 94.32,210.496 210.496,210.496c116.176,0 210.496,-94.32 210.496,-210.496c0,-116.176 -94.32,-210.496 -210.496,-210.496Z" style="fill:currentColor;"/></svg>
|
||||
163
src/main.ts
Normal file
163
src/main.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
import { MarkdownPostProcessorContext, Plugin, TFile } from 'obsidian';
|
||||
import { DEFAULT_SETTINGS, FantasyMapSettings, FantasyMapSettingTab } from "./settings";
|
||||
import { parseFantasyMapParams, fantasyMapHelpMessage } from "./paramaters";
|
||||
import { initMapInteractions } from "./mapInteractions";
|
||||
import { initPinInteractions } from "./pinInteractions";
|
||||
import { destroyCustomPreview } from "./previewInteractions";
|
||||
|
||||
export default class FantasyMap extends Plugin {
|
||||
settings: FantasyMapSettings;
|
||||
|
||||
//#region set up
|
||||
|
||||
// called when the plugin is loaded
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
|
||||
// This adds a settings tab so the user can configure various aspects of the plugin
|
||||
this.addSettingTab(new FantasyMapSettingTab(this.app, this));
|
||||
|
||||
this.registerMarkdownCodeBlockProcessor(
|
||||
"Fantasy-Map",
|
||||
this.main.bind(this)
|
||||
);
|
||||
}
|
||||
|
||||
// called when the plugin is unloaded
|
||||
onunload() {
|
||||
destroyCustomPreview();
|
||||
}
|
||||
|
||||
// This function loads the plugin settings
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<FantasyMapSettings>);
|
||||
}
|
||||
|
||||
// This function saves the current plugin settings
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
async main(source: string, element: HTMLElement, ctx: MarkdownPostProcessorContext) {
|
||||
// get the user parameters set inside the defined code block in the note, and if the map parameter is not set or is empty, quit out of the function
|
||||
const parameters = parseFantasyMapParams(source, element, ctx, this.settings);
|
||||
if (parameters.map == null || parameters.map.trim() === "") return;
|
||||
|
||||
//#region get the map file
|
||||
|
||||
// get the map file by searching for a TFile by linked path, absolute path, or filename in the vault
|
||||
const mapFile: TFile | null = (() => {
|
||||
|
||||
// normalize the map paramater to remove any Obsidian link formatting (e.g. [[map.png]] -> map.png) and trim whitespace
|
||||
const normalized = parameters.map.replace(/^\[\[|$/g, "").trim();
|
||||
|
||||
// return linked file if it's a TFile, which means the user provided a valid Obsidian link to the map file in the parameters
|
||||
const linked = this.app.metadataCache.getFirstLinkpathDest(normalized, ctx.sourcePath);
|
||||
if (linked instanceof TFile) return linked;
|
||||
|
||||
// return absolute file if it's a TFile, which means the user provided a valid absolute path to the map file in the parameters
|
||||
const abs = this.app.vault.getAbstractFileByPath(normalized);
|
||||
if (abs instanceof TFile) return abs;
|
||||
|
||||
// if neither linked nor absolute paths yield a TFile, search the vault for a file with a matching name. This allows users to simply provide the filename of the map without any path or link formatting, as long as the file exists somewhere in the vault.
|
||||
else return this.app.vault.getFiles().find(f => f.name === normalized) ?? null;
|
||||
})();
|
||||
|
||||
// if we can't find the map file, display an error message and quit
|
||||
if (mapFile == null || !mapFile) {
|
||||
await fantasyMapHelpMessage(element, ctx, `<span class=\"fantasy-map-error\">Fantasy Map Error: Map file "${parameters.map}" not found in vault! Double-check the file name and path, and make sure the file is located somewhere in your vault.</span>`);
|
||||
return;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region create map
|
||||
|
||||
// Clear the element and set up the container structure for the map and toolbar
|
||||
element.empty();
|
||||
element.addClass("fantasy-map-container");
|
||||
|
||||
const mapWrapper = element.createEl("div", { cls: "fantasy-map-wrapper" });
|
||||
|
||||
// background: seamless repeat to hide gaps
|
||||
const bgLayer = mapWrapper.createEl("div", { cls: "fantasy-map-background" });
|
||||
|
||||
// foreground tiles: discrete high‑quality tiles
|
||||
const tilesLayer = mapWrapper.createEl("div", { cls: "fantasy-map-tiles" });
|
||||
|
||||
// set image URL
|
||||
const mapUrl = this.app.vault.getResourcePath(mapFile);
|
||||
bgLayer.style.backgroundImage = `url("${mapUrl}")`;
|
||||
|
||||
// load image to infer aspect and tile layout
|
||||
const mapImg = new Image();
|
||||
mapImg.src = mapUrl;
|
||||
mapImg.onload = () => {
|
||||
const ratio = mapImg.naturalHeight / mapImg.naturalWidth;
|
||||
(mapWrapper as any)._mapAspectRatio = ratio;
|
||||
|
||||
const wrapperWidth = mapWrapper.clientWidth;
|
||||
const baseHeight = wrapperWidth * ratio;
|
||||
|
||||
// base height before zoom
|
||||
mapWrapper.style.height = `${baseHeight}px`;
|
||||
|
||||
// background tiling (seamless)
|
||||
bgLayer.style.backgroundRepeat = "repeat"; // always repeat for gap hiding
|
||||
bgLayer.style.backgroundSize = "auto 100%"; // will be overridden by pan/zoom
|
||||
|
||||
// create a small grid of tiles to cover viewport + margins
|
||||
const tilesX = 3; // center + one left + one right
|
||||
const tilesY = 3; // center + one up + one down
|
||||
|
||||
for (let y = 0; y < tilesY; y++) {
|
||||
for (let x = 0; x < tilesX; x++) {
|
||||
const tile = tilesLayer.createEl("div", { cls: "fm-tile" });
|
||||
tile.style.backgroundImage = `url("${mapUrl}")`;
|
||||
tile.style.backgroundRepeat = "no-repeat";
|
||||
tile.style.backgroundSize = "100% 100%";
|
||||
tile.dataset.tileX = String(x);
|
||||
tile.dataset.tileY = String(y);
|
||||
}
|
||||
}
|
||||
|
||||
positionTiles(tilesLayer);
|
||||
|
||||
// pan/zoom now gets both layers
|
||||
initMapInteractions(this.app, mapWrapper, bgLayer, tilesLayer, toolbar, parameters, this.settings);
|
||||
|
||||
//
|
||||
initPinInteractions(this.app, this, mapWrapper, parameters, this.settings, element, ctx);
|
||||
};
|
||||
|
||||
// helper to place tiles in a grid
|
||||
function positionTiles(tilesLayer: HTMLElement) {
|
||||
const tiles = Array.from(tilesLayer.querySelectorAll<HTMLElement>(".fm-tile"));
|
||||
tiles.forEach(tile => {
|
||||
const tx = Number(tile.dataset.tileX);
|
||||
const ty = Number(tile.dataset.tileY);
|
||||
tile.style.left = `${tx * 100}%`;
|
||||
tile.style.top = `${ty * 100}%`;
|
||||
});
|
||||
}
|
||||
|
||||
// Create a toolbar div to hold the zoom controls and zoom increment input
|
||||
const toolbar = mapWrapper.createEl("div", { cls: "fantasy-map-toolbar" });
|
||||
|
||||
// Create zoom in, reset, and zoom out buttons
|
||||
toolbar.createEl("button", { cls: "fm-zoom-in", text: "+" });
|
||||
toolbar.createEl("button", { cls: "fm-reset", text: "Reset" });
|
||||
toolbar.createEl("button", { cls: "fm-zoom-out", text: "-" });
|
||||
|
||||
// Create a zoom increment input field, pre-filled with the default zoom increment value from the parameters (or settings if not specified in the parameters)
|
||||
const zoomInput = toolbar.createEl("input", {
|
||||
cls: "fm-zoom-step",
|
||||
type: "number",
|
||||
}) as HTMLInputElement;
|
||||
zoomInput.value = String(parameters.defaultZoomIncrement ?? this.settings.defaultZoomIncrement);
|
||||
//#endregion
|
||||
}
|
||||
}
|
||||
204
src/mapInteractions.ts
Normal file
204
src/mapInteractions.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { App } from "obsidian";
|
||||
import { React } from "react";
|
||||
import { updatePinPositions } from "pinInteractions";
|
||||
|
||||
export interface PanZoomState {
|
||||
zoom: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
}
|
||||
|
||||
let isPanning: boolean = false;
|
||||
let wrapper: HTMLElement | null = null;
|
||||
let lastX: number = 0;
|
||||
let lastY: number = 0;
|
||||
|
||||
export function initMapInteractions(
|
||||
app: APP,
|
||||
wrapper: HTMLElement,
|
||||
bgLayer: HTMLElement,
|
||||
tilesLayer: HTMLElement,
|
||||
toolbar: HTMLElement,
|
||||
paramaters: FantasyMapParams,
|
||||
settings: FantasyMapSettings
|
||||
) {
|
||||
const state: PanZoomState = {
|
||||
zoom: 1,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
minZoom: 1,
|
||||
maxZoom: 15
|
||||
};
|
||||
|
||||
this.wrapper = wrapper;
|
||||
|
||||
function applyTransform() {
|
||||
// size of one world tile in screen space at current zoom
|
||||
const tileWidth = wrapper.clientWidth * state.zoom;
|
||||
const tileHeight = wrapper.clientHeight * state.zoom;
|
||||
|
||||
// wrap offsets so tiles never drift far off screen
|
||||
const wrappedX = (state.offsetX % tileWidth) - tileWidth;
|
||||
const wrappedY = (state.offsetY % tileHeight) - tileHeight;
|
||||
|
||||
// tiles: transform via CSS scale/translate
|
||||
const transform = `translate(${wrappedX}px, ${wrappedY}px) scale(${state.zoom})`;
|
||||
tilesLayer.style.transform = transform;
|
||||
tilesLayer.style.transformOrigin = "top left";
|
||||
|
||||
// background: matching zoom/pan via background-size/position
|
||||
const widthPercent = 100 * state.zoom;
|
||||
bgLayer.style.backgroundSize = `${widthPercent}% auto`;
|
||||
bgLayer.style.backgroundPosition = `${wrappedX}px ${wrappedY}px`;
|
||||
|
||||
updatePinPositions(state.offsetX, state.offsetY, tileWidth, tileHeight);
|
||||
}
|
||||
|
||||
function clampOffsets() {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const viewW = rect.width;
|
||||
const viewH = rect.height;
|
||||
|
||||
const worldW = viewW * state.zoom;
|
||||
const worldH = viewH * state.zoom;
|
||||
|
||||
const repeat = paramaters.repeat ?? "no-repeat";
|
||||
|
||||
let minX = - (worldW - viewW);
|
||||
let maxX = 0;
|
||||
let minY = - (worldH - viewH);
|
||||
let maxY = 0;
|
||||
|
||||
switch (repeat) {
|
||||
case "repeat-x":
|
||||
// infinite horizontally, clamped vertically
|
||||
minX = -Infinity;
|
||||
maxX = Infinity;
|
||||
break;
|
||||
case "repeat-y":
|
||||
// infinite vertically, clamped horizontally
|
||||
minY = -Infinity;
|
||||
maxY = Infinity;
|
||||
break;
|
||||
case "repeat":
|
||||
case "space":
|
||||
case "round":
|
||||
// infinite both directions
|
||||
minX = -Infinity;
|
||||
maxX = Infinity;
|
||||
minY = -Infinity;
|
||||
maxY = Infinity;
|
||||
break;
|
||||
case "no-repeat":
|
||||
default:
|
||||
// clamped both directions
|
||||
break;
|
||||
}
|
||||
|
||||
if (Number.isFinite(minX)) state.offsetX = Math.max(minX, Math.min(maxX, state.offsetX));
|
||||
if (Number.isFinite(minY)) state.offsetY = Math.max(minY, Math.min(maxY, state.offsetY));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.zoom = paramaters.defaultZoomLevel || 1;
|
||||
state.offsetX = 0;
|
||||
state.offsetY = 0;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function zoomAt(clientX: number, clientY: number, factor: number) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
|
||||
const px = clientX - rect.left;
|
||||
const py = clientY - rect.top;
|
||||
|
||||
const worldX = (px - state.offsetX) / state.zoom;
|
||||
const worldY = (py - state.offsetY) / state.zoom;
|
||||
|
||||
const newZoom = Math.min(
|
||||
state.maxZoom,
|
||||
Math.max(state.minZoom, state.zoom * factor)
|
||||
);
|
||||
|
||||
state.offsetX = px - worldX * newZoom;
|
||||
state.offsetY = py - worldY * newZoom;
|
||||
state.zoom = newZoom;
|
||||
|
||||
clampOffsets();
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
wrapper.addEventListener("pointerdown", (e) => {
|
||||
if (toolbar.contains(e.target as Node)) return;
|
||||
|
||||
isPanning = true;
|
||||
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
|
||||
wrapper.setPointerCapture(e.pointerId);
|
||||
});
|
||||
|
||||
wrapper.addEventListener("pointermove", (e) => {
|
||||
if (!isPanning) {
|
||||
this.wrapper.releasePointerCapture(e.pointerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = e.clientX - lastX;
|
||||
const dy = e.clientY - lastY;
|
||||
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
|
||||
state.offsetX += dx;
|
||||
state.offsetY += dy;
|
||||
|
||||
clampOffsets();
|
||||
applyTransform();
|
||||
});
|
||||
|
||||
wrapper.addEventListener("pointerup", async (e) => { cancelMapPanning(); });
|
||||
|
||||
wrapper.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
const zoomStep = toolbar.querySelector(".fm-zoom-step") as HTMLInputElement;
|
||||
const factor = e.deltaY < 0 ? (1 + zoomStep.value * 0.1) : 1 / (1 + zoomStep.value * 0.1);
|
||||
zoomAt(e.clientX, e.clientY, factor);
|
||||
}, { passive: false });
|
||||
|
||||
const zoomInBtn = toolbar.querySelector(".fm-zoom-in") as HTMLButtonElement;
|
||||
const zoomOutBtn = toolbar.querySelector(".fm-zoom-out") as HTMLButtonElement;
|
||||
const resetBtn = toolbar.querySelector(".fm-reset") as HTMLButtonElement;
|
||||
|
||||
function zoomAtViewportCenter(zoomFactor: number) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + (rect.height - toolbar.clientHeight) / 2;
|
||||
zoomAt(cx, cy, zoomFactor);
|
||||
}
|
||||
|
||||
zoomInBtn?.addEventListener("click", () => {
|
||||
const stepInput = toolbar.querySelector(".fm-zoom-step") as HTMLInputElement;
|
||||
const step = Math.abs(Number(stepInput?.value)) || paramaters.defaultZoomIncrement || settings.defaultZoomIncrement;
|
||||
const factorBase = 1 + step * 0.1;
|
||||
zoomAtViewportCenter(factorBase);
|
||||
});
|
||||
|
||||
zoomOutBtn?.addEventListener("click", () => {
|
||||
const stepInput = toolbar.querySelector(".fm-zoom-step") as HTMLInputElement;
|
||||
const step = Math.abs(Number(stepInput?.value)) || paramaters.defaultZoomIncrement || settings.defaultZoomIncrement;
|
||||
const factorBase = 1 + step * 0.1;
|
||||
zoomAtViewportCenter(1 / factorBase);
|
||||
});
|
||||
|
||||
resetBtn?.addEventListener("click", () => reset());
|
||||
|
||||
reset();
|
||||
}
|
||||
|
||||
export function cancelMapPanning() {
|
||||
isPanning = false;
|
||||
}
|
||||
346
src/paramaters.ts
Normal file
346
src/paramaters.ts
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
import {App, MarkdownRenderer} from "obsidian";
|
||||
import {FantasyMapSettings} from "./settings";
|
||||
|
||||
// This interface defines the structure of the parameters that can be passed to the map renderer, including the required "map" parameter and various optional parameters with their corresponding types
|
||||
export interface FantasyMapParams {
|
||||
map: string;
|
||||
mapIDs: string[];
|
||||
defaultZoomIncrement?: number;
|
||||
defaultZoomLevel?: number;
|
||||
defaultLocation?: [number, number];
|
||||
repeat?: string;
|
||||
latitudeRange?: [number, number];
|
||||
longitudeRange?: [number, number];
|
||||
longitudeOffset?: number;
|
||||
unitOfMeasurement?: string;
|
||||
equatorialCircumference?: number;
|
||||
}
|
||||
|
||||
// Help message strings for each parameter, which will be displayed when the user includes a help keyword in the value of any parameter; these messages provide detailed instructions on how to use each parameter and what values are accepted
|
||||
const mapHelpMessageString = "**\"Map\"** (**required**): The name of the map asset to display - supports relative paths, absolute paths, and bare filenames (e.g. \"World Map.svg\") - the file must be located somewhere in your vault - supported file types are SVG, PNG, JPG, JPEG, WEBP, and GIF"
|
||||
const mapIDsHelpMessageString = "**\"Map IDs\"** (optional): A comma-separated list of IDs that define which pins with the same ID can appear on the map (e.g. \"river, mountain\"); if not specified, all pins will be displayed on the map regardless of their ID";
|
||||
const defaultZoomIncrementHelpMessageString = "**\"Default Zoom Increment\"** (optional): The zoom increment amount – default: \"1\", can be adjusted in the plugin settings window";
|
||||
const defaultZoomLevelHelpMessageString = "**\"Default Zoom Level\"** (optional): The initial zoom level when the map is first loaded – default: \"1\"";
|
||||
const defaultLocationHelpMessageString = "**\"Default Location\"** (optional): The initial focused coordinates for the map on load \"(latitude, longitude)\" – default: \"(0, 0)\"";
|
||||
const repeatHelpMessageString = "**\"Repeat\"** (optional): Indicates if the map should be repeated horizontally, vertically, or in both directions (accepts yes/no, true/false, etc.; defaults to horizontal repetition. Use ver/vertical/y, hor/horizontal/x, or 'both/b' to enable the respective options) – default: \"none\"";
|
||||
const latitudeRangeHelpMessageString = "**\"Latitude Range\"** (optional): The latitude bounds as \"(min, max)\" – default: \"(-90, 90)\"";
|
||||
const longitudeRangeHelpMessageString = "**\"Longitude Range\"** (optional): The longitude bounds as \"(min, max)\" – default: \"(0, 360)\"";
|
||||
const longitudeOffsetHelpMessageString = "**\"Longitude Offset\"** (optional): Offset for longitude values – default: \"0\"";
|
||||
const unitOfMeasurementHelpMessageString = "**\"Unit Of Measurement\"** (optional): Unit system (\"Metric\"/\"Imperial\") – default: \"Metric\", can be adjusted in the plugin settings window";
|
||||
const equatorialCircumferenceHelpMessageString = "**\"Equatorial Circumference\"** (optional): Custom equatorial circumference value – default: \"40075\" for Metric in kilometers, \"24901\" for Imperial in Miles";
|
||||
const helpHelpMessageString = "**\"Help, -H, Info, Instruction, -I, or Usage\"**: You can also include a parameter with any of these keywords to display this message without an error prefix if you just want to show the usage instructions";
|
||||
|
||||
function allHelpMessages(settings: FantasyMapSettings) : string[] {
|
||||
return [
|
||||
mapHelpMessageString,
|
||||
mapIDsHelpMessageString,
|
||||
defaultZoomIncrementHelpMessageString.replace("default: \"1\"", `default: \"${settings.defaultZoomIncrement}\"`),
|
||||
defaultZoomLevelHelpMessageString,
|
||||
/* defaultLocationHelpMessageString,*/
|
||||
repeatHelpMessageString,
|
||||
latitudeRangeHelpMessageString,
|
||||
longitudeRangeHelpMessageString,
|
||||
/* longitudeOffsetHelpMessageString,*/
|
||||
/* unitOfMeasurementHelpMessageString.replace("default: \"Metric\"", `default: \"${settings.defaultUnitOfMeasurement}\"`),*/
|
||||
/* equatorialCircumferenceHelpMessageString,*/
|
||||
helpHelpMessageString
|
||||
];
|
||||
}
|
||||
|
||||
// This string provides a template for users to copy and paste into their notes to create a new Fantasy-Map code block with all available parameters listed for easy reference
|
||||
export const fantasyMapCopyToClipboardString = [
|
||||
"```Fantasy-Map",
|
||||
"Map:",
|
||||
"MapIDs:",
|
||||
"Default Zoom Increment:",
|
||||
"Default Zoom Level:",
|
||||
/* "Default Center:",*/
|
||||
"repeat:",
|
||||
"Latitude Range:",
|
||||
"Longitude Range:",
|
||||
/* "Longitude Offset:",*/
|
||||
/* "Unit Of Measurement:",*/
|
||||
/* "Equatorial Circumference:",*/
|
||||
"```"
|
||||
].join("\n");
|
||||
|
||||
// Comprehensive list of affirmative values that will be interpreted as true for boolean parameters like "repeat"; this allows for a wide range of user inputs to enable the feature without requiring strict formatting
|
||||
const affirmatives = [
|
||||
"yes", "y",
|
||||
"true", "t",
|
||||
"1",
|
||||
"on",
|
||||
"enable", "enabled",
|
||||
"ok", "okay",
|
||||
"sure",
|
||||
"yep", "yeah", "yea", "yup",
|
||||
"affirmative",
|
||||
"positive",
|
||||
"confirm", "confirmed"
|
||||
];
|
||||
|
||||
const verticalrepeatAffirmatives = [
|
||||
"vertical", "vert", "ver", "vr", "v", "y"
|
||||
];
|
||||
|
||||
const horizontalrepeatAffirmatives = [
|
||||
"horizontal", "hori", "hor", "hr", "h", "x"
|
||||
]
|
||||
|
||||
const bothrepeatAffirmatives = [
|
||||
"both", "b"
|
||||
]
|
||||
|
||||
// Comprehensive list of values that will be interpreted as Imperial units for the "unitOfMeasurement" parameter; any value that matches one of these (case-insensitive) will set the measurement system to Imperial, while any other value will default to Metric
|
||||
const imperialUnits = [
|
||||
"imperial",
|
||||
"us customary"
|
||||
];
|
||||
|
||||
// Comprehensive list of help keywords to trigger the usage instructions
|
||||
const helpKeywords = [
|
||||
"help",
|
||||
"-h",
|
||||
"info",
|
||||
"instruction",
|
||||
"-i",
|
||||
"usage"
|
||||
];
|
||||
|
||||
// This function parses the parameters from the code block content and returns an object with the corresponding values, applying defaults where necessary
|
||||
export function parseFantasyMapParams(source: string, element: HTMLElement, ctx: MarkdownPostProcessorContext, settings: FantasyMapSettings): FantasyMapParams {
|
||||
|
||||
// split the code block content into lines, trim whitespace, and filter out any empty lines to prepare for parsing; this allows users to format their parameters with extra spaces or blank lines without affecting the parsing logic
|
||||
const lines = source.split("\n").map(l => l.trim()).filter(Boolean);
|
||||
const params: any = {};
|
||||
const helpMessages = [];
|
||||
|
||||
// This function adds a help message to the helpMessages array if it hasn't already been added, and logs the added message to the console for debugging purposes; this ensures that each help message is only added once, even if multiple parameters contain help keywords
|
||||
function addHelpMessage(message: string) {
|
||||
if (!helpMessages.contains(message)) helpMessages.push(message);
|
||||
}
|
||||
|
||||
// if there are no lines of content in the code block, display a help message with usage instructions to guide users on how to use the plugin and what parameters are available; this provides a helpful starting point for users who may be new to the plugin or unsure of how to format their parameters
|
||||
if (lines.length == 0) fantasyMapHelpMessage(element, ctx, compileHelpMessages(allHelpMessages(settings), "##### Available Fantasy Map Parameters:"), true);
|
||||
|
||||
// iterate through each line of the code block content, parsing the key and value for each parameter and applying the appropriate logic based on the parameter type and expected format; if a help keyword is detected in the value of any parameter, the corresponding help message will be added to the helpMessages array and displayed to the user without an error prefix
|
||||
else for (const line of lines) {
|
||||
|
||||
// if the line contains any help keywords, add the corresponding help message to the helpMessages array (if it hasn't already been added) and skip further processing for this line, allowing users to include a help keyword on its own line to get usage instructions without needing to trigger an error
|
||||
if (helpKeywords.includes(line)){
|
||||
allHelpMessages(settings).forEach(message => {addHelpMessage(message)});
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the line does not contain a colon, log a warning to the console and display an error message with usage instructions, but continue processing the remaining lines to allow users to correct formatting issues without losing all of their parameters; this also allows for more flexible formatting of the parameters in the code block, as users can include comments or other non-parameter lines without breaking the entire block
|
||||
if (!line.contains(":")){
|
||||
addHelpMessage(`<span class=\"fantasy-map-error\">Fantasy Map Error: Invalid parameter format: "${line}"! Each parameter should be in the format "key: value". Please check for typos and make sure you are using the correct format.</span>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// split each line into a key and value based on the first colon, allowing for values that contain colons (e.g. file paths); if there is no colon or the value is empty, skip this line
|
||||
const [rawKey, ...rawValue] = line.split(":");
|
||||
if (!rawKey || rawValue.length === 0) continue;
|
||||
|
||||
// trim whitespace from the key and value to ensure that extra spaces do not affect the parsing logic; this allows for more flexible formatting of the parameters in the code block
|
||||
const key = rawKey.trim().replace(/\s+/g, "").toLowerCase();
|
||||
const value = rawValue.join(":").trim();
|
||||
|
||||
// This function checks if the value contains any help keywords, and if so, adds the corresponding help message to the helpMessages array (if it hasn't already been added) and returns true to indicate that a help message should be displayed; if the value does not contain any help keywords, it simply returns false
|
||||
function checkForHelp(message: string) : boolean {
|
||||
const helpRequested = helpKeywords.contains(value.toLowerCase());
|
||||
if (helpRequested) addHelpMessage(message);
|
||||
return helpRequested;
|
||||
}
|
||||
|
||||
// if the value contains any help keywords, add the corresponding help message to the helpMessages array (if it hasn't already been added) and skip further processing for this parameter, allowing users to include a help keyword in any parameter to get usage instructions without needing to trigger an error
|
||||
if (helpKeywords.includes(key)){
|
||||
if (checkForHelp(helpHelpMessageString)) continue;
|
||||
allHelpMessages(settings).forEach(message => {addHelpMessage(message)});
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
|
||||
// set the name of the map to load
|
||||
case "map":
|
||||
|
||||
// help message for the map parameter if the value contains any help keywords
|
||||
if (checkForHelp(mapHelpMessageString)) break;
|
||||
|
||||
if (value.trim().length == 0) addHelpMessage("<span class=\"fantasy-map-error\">Fantasy Map ERROR: map option is required!</span>") ;
|
||||
|
||||
// set the name of the map to load; supports relative paths, absolute paths, and bare filenames (e.g. "World Map.svg") - the file must be located somewhere in your vault - supported file types are SVG, PNG, JPG, JPEG, WEBP, and GIF
|
||||
params.map = value;
|
||||
break;
|
||||
|
||||
case "mapids":
|
||||
|
||||
// help message for the mapIDs parameter if the value contains any help keywords
|
||||
if (checkForHelp(mapIDsHelpMessageString)) break;
|
||||
|
||||
// break the value into a comma-separated list of IDs
|
||||
params.mapIDs = value.split(",").map(id => id.trim());
|
||||
break;
|
||||
|
||||
// set the default zoom increment, defaulting to 1 if the value is not a valid number
|
||||
case "defaultzoomincrement":
|
||||
|
||||
// help message for the defaultZoomIncrement parameter if the value contains any help keywords
|
||||
if (checkForHelp(defaultZoomIncrementHelpMessageString.replace("default: \"1\"", `default: \"${settings.defaultZoomIncrement}\"`))) break;
|
||||
|
||||
// if the value is not a valid number, default to 1
|
||||
params.defaultZoomIncrement = Number(value) || 1;
|
||||
break;
|
||||
|
||||
// set the default zoom level, defaulting to 1 if the value is not a valid number
|
||||
case "defaultzoomlevel":
|
||||
|
||||
// help message for the defaultZoomLevel parameter if the value contains any help keywords
|
||||
if (checkForHelp(defaultZoomLevelHelpMessageString)) break;
|
||||
|
||||
//check value is a number and is greater than 0, otherwise default to 1
|
||||
params.defaultZoomLevel = Number(value) > 0 ? Number(value) : 1;
|
||||
break;
|
||||
|
||||
// get the default center coordinates in the format (latitude, longitude), allowing for optional whitespace and decimal numbers; if the value is not in the correct format, it will be ignored and the default center will be (0, 0)
|
||||
case "defaultlocation":
|
||||
|
||||
// help message for the defaultLocation parameter if the value contains any help keywords
|
||||
if (checkForHelp(defaultLocationHelpMessageString)) break;
|
||||
|
||||
// get the default center coordinates in the format (latitude, longitude), allowing for optional whitespace and decimal numbers; if the value is not in the correct format, it will be ignored and the default center will be (0, 0)
|
||||
const defaultLocationRegMatch = value.match(/\((-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)\)/);
|
||||
if (defaultLocationRegMatch) params.defaultLocation = [Number(defaultLocationRegMatch[1]), Number(defaultLocationRegMatch[2])];
|
||||
break;
|
||||
|
||||
// set repeat to true if the value is an affirmative, false otherwise
|
||||
case "repeat":
|
||||
|
||||
// help message for the repeat parameter if the value contains any help keywords
|
||||
if (checkForHelp(repeatHelpMessageString)) break;
|
||||
|
||||
// get the repeat method by cross checking which repeat option was provided
|
||||
params.repeat = (() => {
|
||||
|
||||
// normalize the value provided and check which repeat option it matches with any repeating behavioural key words
|
||||
const normalizedValue = value.toLowerCase();
|
||||
|
||||
// if the option provided was specifying for vertical repeating, set repeat to "repeat-y" to repeat the map vertically
|
||||
if (verticalrepeatAffirmatives.includes(normalizedValue))
|
||||
return "repeat-y";
|
||||
|
||||
// if the option provided was a general affirmative or specifying for horizontal repeating, set repeat to "repeat-x" to repeat the map horizontally
|
||||
else if (affirmatives.includes(normalizedValue) || horizontalrepeatAffirmatives.includes(normalizedValue))
|
||||
return "repeat-x";
|
||||
|
||||
// if the option provided was specifying for both horizontal and vertical repeating, set repeat to "repeat" to repeat the map in both directions;
|
||||
else if (bothrepeatAffirmatives.includes(normalizedValue))
|
||||
return "repeat";
|
||||
|
||||
// if none of these options were provided, default to "no-repeat" to not repeat the map
|
||||
else return "no-repeat";
|
||||
|
||||
})();
|
||||
|
||||
break;
|
||||
|
||||
// get latitude and longitude ranges in the format (min, max), allowing for optional whitespace and decimal numbers
|
||||
case "latituderange":
|
||||
case "longituderange":
|
||||
|
||||
// help message for the LatitudeRange and LongitudeRange parameters if the value contains any help keywords
|
||||
if (checkForHelp(key == "latituderange" ? latitudeRangeHelpMessageString : longitudeRangeHelpMessageString)) break;
|
||||
|
||||
// get latitude and longitude ranges in the format (min, max), allowing for optional whitespace and decimal numbers; if the value is not in the correct format, it will be ignored and the default range will be used (LatitudeRange: (-90, 90), LongitudeRange: (0, 360))
|
||||
const latitudeLongitudeRegMatch = value.match(/\((-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)\)/);
|
||||
if (latitudeLongitudeRegMatch) params[key === "latituderange" ? "latituderange" : "longituderange"] = [Number(latitudeLongitudeRegMatch[1]), Number(latitudeLongitudeRegMatch[2])];
|
||||
break;
|
||||
|
||||
// get the longitude offset, defaulting to 0 if the value is not a valid number (used for maps that have a different prime meridian other than the center of the map)
|
||||
case "longitudeoffset":
|
||||
|
||||
// help message for the LongitudeOffset parameter if the value contains any help keywords
|
||||
if (checkForHelp(longitudeOffsetHelpMessageString)) break;
|
||||
|
||||
// get the longitude offset, defaulting to 0 if the value is not a valid number (used for maps that have a different prime meridian other than the center of the map)
|
||||
params.longitudeOffset = Number(value) || 0;
|
||||
break;
|
||||
|
||||
// get the measurement units, defaulting to Metric if the value is not recognized as Imperial
|
||||
case "unitofmeasurement":
|
||||
|
||||
// help message for the UnitOfMeasurement parameter if the value contains any help keywords
|
||||
if (checkForHelp(unitOfMeasurementHelpMessageString.replace("default: \"Metric\"", `default: \"${settings.defaultUnitOfMeasurement}\"`))) break;
|
||||
|
||||
// get the measurement units, defaulting to Metric if the value is not recognized as Imperial (e.g. "Imperial", "US Customary"); this will affect how distances and other measurements are displayed on the map
|
||||
params.measurementUnits = imperialUnits.contains(value.toLowerCase()) ? "Imperial" : "Metric";
|
||||
break;
|
||||
|
||||
// get the equatorial length, allowing for values with commas (e.g. 40,075)
|
||||
case "equatorialcircumference":
|
||||
|
||||
// help message for the equatorialCircumference parameter if the value contains any help keywords
|
||||
if (checkForHelp(equatorialCircumferenceHelpMessageString)) break;
|
||||
|
||||
// get the equatorial length, allowing for values with commas (e.g. 40,075); if the value is not a valid number, it will default to 40,075 for Metric (in kilometers) or 24,901 for Imperial (in miles)
|
||||
const num = Number(value.replace(/,/g, ""));
|
||||
if (!isNaN(num)) params.equatorialCircumference = num;
|
||||
break;
|
||||
|
||||
// if the key is not recognized, log a warning to the console but do not throw an error, allowing for flexibility in parameter formatting and the inclusion of additional parameters that may be used by other parts of the plugin or future updates
|
||||
default:
|
||||
console.warn(`Unknown Fantasy Map parameter: \"${rawKey}\"`);
|
||||
addHelpMessage(`Unknown parameter: "${rawKey}"! Please check for typos and make sure you are using the correct parameter names.`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Defaults
|
||||
if (params.defaultZoomIncrement == null) params.defaultZoomIncrement = settings.defaultZoomIncrement;
|
||||
if (params.defaultZoomLevel == null) params.defaultZoomLevel = 1;
|
||||
if (params.defaultLocation == null) params.defaultLocation = [0, 0];
|
||||
if (params.repeat == null) params.repeat = "no-repeat";
|
||||
if (params.latitudeRange == null) params.latitudeRange = [-90, 90];
|
||||
if (params.longitudeRange == null) params.longitudeRange = [0, 360];
|
||||
if (params.longitudeOffset == null) params.longitudeOffset = 0;
|
||||
if (params.unitOfMeasurement == null) params.unitOfMeasurement = settings.defaultUnitOfMeasurement;
|
||||
if (params.equatorialCircumference == null) params.equatorialCircumference = params.measurementUnits === "Metric" ? 40075 : 24901;
|
||||
|
||||
if (helpMessages.length > 0) fantasyMapHelpMessage(element, ctx, compileHelpMessages(helpMessages, "##### Fantasy Map Help Messages:"), true);
|
||||
|
||||
return params as FantasyMapParams;
|
||||
}
|
||||
|
||||
// Render a help message to markdown
|
||||
export async function fantasyMapHelpMessage(element: HTMLElement, ctx: MarkdownPostProcessorContext, message: string, includeCopyLink: boolean = false) {
|
||||
|
||||
// Render the markdown message in the code block container
|
||||
const container = element.createDiv();
|
||||
await MarkdownRenderer.renderMarkdown(message ?? "", container, ctx.sourcePath, this as any);
|
||||
if (includeCopyLink) appendCopyLink(container);
|
||||
}
|
||||
|
||||
// This function appends a "Copy Fantasy-Map template" link to the provided container element, which when clicked will copy the predefined fantasyMapCopyToClipboardString to the user's clipboard and provide feedback by temporarily changing the link text to "Copied!" before reverting back to the original text after 1.5 seconds; this allows users to easily copy a template for creating new Fantasy-Map code blocks without needing to manually select and copy the text
|
||||
export function appendCopyLink(container: HTMLElement) {
|
||||
const repeatper = container.createDiv({cls: "fantasy-map-copy-link"});
|
||||
const link = repeatper.createEl("a", {text: "Copy Fantasy-Map template", href: "#"});
|
||||
link.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await navigator.clipboard.writeText(fantasyMapCopyToClipboardString);
|
||||
link.setText("Copied!");
|
||||
setTimeout(() => link.setText("Copy Fantasy-Map template"), 1500);
|
||||
} catch (err) {
|
||||
console.warn("Fantasy-Map copy failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function compileHelpMessages(messages: string[], title: string) : string {
|
||||
if (messages == null || messages.length == 0) return null;
|
||||
|
||||
const message = [title?? null];
|
||||
messages.forEach(m => {message.push("- {0}".replace("{0}", m))});
|
||||
return message.join("\n");
|
||||
}
|
||||
289
src/pinInteractions.ts
Normal file
289
src/pinInteractions.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import {App, CachedMetadata, MarkdownPostProcessorContext, MarkdownRenderer, Plugin, TFile, Component } from "obsidian";
|
||||
import { FantasyMapSettings } from "./settings";
|
||||
import { FantasyMapParams } from "./paramaters";
|
||||
import { updateMapInnerSize } from "./mapRenderer";
|
||||
import { cancelMapPanning } from "./mapInteractions";
|
||||
import { showCustomPreview, hideCustomPreview, destroyCustomPreview } from "./previewInteractions"
|
||||
|
||||
import pinIcon from "./assets/mapPinIcon_customizable.svg";
|
||||
|
||||
export interface Pin {
|
||||
note: TFile;
|
||||
element: HTMLElement;
|
||||
location: Location;
|
||||
}
|
||||
|
||||
const Pins: Pin[] = [];
|
||||
var selectedPin: Pin | null = null;
|
||||
var enablePinDrag = false;
|
||||
var disablePreviews = false;
|
||||
var latBounds: { min: number, max: number } = { min: 0, max: 0 };
|
||||
var lngBounds: { min: number, max: number } = { min: 0, max: 0 };
|
||||
|
||||
var currentMap: { width: number, height: number, xOffset: number, yOffset: number } = { width: 0, height: 0, xOffset: 0, yOffset: 0 };
|
||||
|
||||
export async function initPinInteractions(
|
||||
app: App,
|
||||
component: Component,
|
||||
wrapper: HTMLElement,
|
||||
paramaters: FantasyMapParams,
|
||||
settings: FantasyMapSettings,
|
||||
element: HTMLElement,
|
||||
ctx: MarkdownPostProcessorContext,
|
||||
) {
|
||||
|
||||
disablePreviews = false;
|
||||
|
||||
// collect all notes that contain fantasy map front matter and store them in pins
|
||||
const notes = app.vault.getMarkdownFiles();
|
||||
|
||||
currentMap.width = wrapper.clientWidth;
|
||||
currentMap.height = wrapper.clientHeight;
|
||||
|
||||
// define the map latitude and longitude bounds
|
||||
latBounds = { min: paramaters.latitudeRange[0], max: paramaters.latitudeRange[1] };
|
||||
lngBounds = { min: paramaters.longitudeRange[0], max: paramaters.longitudeRange[1] };
|
||||
|
||||
// iterate through all note files to find all location relavent notes to attach to the map
|
||||
for (const note of notes) {
|
||||
createPin(note);
|
||||
}
|
||||
|
||||
function createPin(note: TFile){
|
||||
//#region parse and filter note front matter
|
||||
|
||||
// get the note front matter; if no front matter detected, skip the note
|
||||
const cache = app.metadataCache.getFileCache(note);
|
||||
const frontMatter = cache?.frontmatter;
|
||||
if (!frontMatter) return;
|
||||
|
||||
// if mapIDs parameter is set, only include notes with a matching fantasy-map-id in their front matter
|
||||
if (paramaters.mapIDs.length > 0 && paramaters.mapIDs[0] !== "") {
|
||||
const mapId = frontMatter["fantasy-map-id"];
|
||||
if (mapId !== undefined && !paramaters.mapIDs.includes(mapId)) return;
|
||||
}
|
||||
|
||||
// get location from front matter and parse it; if no location or invalid format, skip the note
|
||||
const frontMatterLc = frontMatter["fantasy-map-location"];
|
||||
if (frontMatterLc === undefined) return;
|
||||
|
||||
const location = parseFormattedLocation(frontMatterLc);
|
||||
if (!location) return;
|
||||
|
||||
// skip locations that are outside the map bounds
|
||||
if (location.lat < latBounds.min || location.lat > latBounds.max || location.lng < lngBounds.min || location.lng > lngBounds.max) return``;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region create pin icon
|
||||
|
||||
const element = wrapper.createEl("div", { cls: "map-pin" });
|
||||
//attachPinHoverBehaviour(this, pin, app); !!
|
||||
|
||||
// translate the location's latitude and longitude to a px of the map bounds for CSS positioning and apply them to the pin element
|
||||
const formattedPx = formatPx(locationToPx(location));
|
||||
element.style.left = formattedPx.left;
|
||||
element.style.top = formattedPx.top;
|
||||
|
||||
// get the pin icon and add it to the pin element; set the width of the icon to 12px
|
||||
const pinIconEl = element.createEl("div", { cls: "map-pin-icon" });
|
||||
pinIconEl.innerHTML = pinIcon;
|
||||
pinIconEl.style.width = `12px`;
|
||||
//#endregion
|
||||
|
||||
const newPin = { note, element, location }
|
||||
|
||||
initPinActions(newPin, app, component);
|
||||
|
||||
Pins.push(newPin);
|
||||
}
|
||||
|
||||
wrapper.addEventListener("pointermove", (e) => {
|
||||
if (!selectedPin) return;
|
||||
|
||||
if (!enablePinDrag) {
|
||||
selectedPin = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const formattedPx = formatPx(mouseToPx(e, wrapper.getBoundingClientRect()));
|
||||
selectedPin.element.style.left = formattedPx.left;
|
||||
selectedPin.element.style.top = formattedPx.top;
|
||||
});
|
||||
|
||||
wrapper.addEventListener("pointerup", async (e) => {
|
||||
if (!selectedPin) return;
|
||||
|
||||
if (selectedPin.note) {
|
||||
if (enablePinDrag) {
|
||||
const px = mouseToPx(e, wrapper.getBoundingClientRect());
|
||||
|
||||
px.left = wrapValue(px.left - currentMap.xOffset, currentMap.width);
|
||||
px.top = wrapValue(px.top - currentMap.yOffset, currentMap.height);
|
||||
|
||||
selectedPin.location = pxToLocation(px);
|
||||
await app.fileManager.processFrontMatter(selectedPin.note, (frontmatter) => {
|
||||
frontmatter["fantasy-map-location"] = formatLocation(selectedPin.location);
|
||||
});
|
||||
}
|
||||
else {
|
||||
disablePreviews = true;
|
||||
destroyCustomPreview();
|
||||
app.workspace.openLinkText(selectedPin.note.path, "", false);
|
||||
}
|
||||
}
|
||||
|
||||
enablePinDrag = false;
|
||||
selectedPin = null;
|
||||
})
|
||||
|
||||
wrapper.addEventListener("pointerleave", (e) => {
|
||||
if (enablePinDrag){
|
||||
enablePinDrag = false;
|
||||
selectedPin = null;
|
||||
updatePinPositions(currentMap.xOffset, currentMap.yOffset, currentMap.width, currentMap.height);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//#region Helper functions for translating between location and px coordinates, and handling mouse position and hover timeouts
|
||||
|
||||
interface Location { lat: number; lng: number }
|
||||
interface Position { left: number; top: number }
|
||||
interface FormattedPosition { left: string; top: string }
|
||||
|
||||
//#region functions to format and parse location and px coordinates for use between css styles and front matter
|
||||
|
||||
function formatLocation(location: Location): string {
|
||||
return `(${location.lat.toFixed(4)}, ${location.lng.toFixed(4)})`;
|
||||
}
|
||||
|
||||
function parseFormattedLocation(formattedLocation: string): Location | null {
|
||||
const locationRegex = formattedLocation.match(/\(\s*([+-]?\d+(?:\.\d+)?)\s*,\s*([+-]?\d+(?:\.\d+)?)\s*\)/);
|
||||
if (!locationRegex) return null;
|
||||
|
||||
const location = { lat: Number(locationRegex[1]), lng: Number(locationRegex[2])}
|
||||
if (!location || Number.isNaN(location.lat) || Number.isNaN(location.lng)) return null;
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
function formatPx(px: Position): FormattedPosition{
|
||||
return {
|
||||
left: `${px.left}px`,
|
||||
top: `${px.top}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function parseFormattedPx(formattedPx: FormattedPosition): Position | null {
|
||||
const leftMatch = formattedPx.left.match(/(-?\d+(?:\.\d+)?)\s*px/);
|
||||
const topMatch = formattedPx.top.match(/(-?\d+(?:\.\d+)?)\s*px/);
|
||||
if (!leftMatch || !topMatch) return null;
|
||||
|
||||
return {
|
||||
left: Number(leftMatch[1]),
|
||||
top: Number(topMatch[1])
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region functions to translate between location and px coordinates, and handle mouse position
|
||||
|
||||
function locationToPx(locVal: Location) : Position {
|
||||
return {
|
||||
left: (locVal.lng - lngBounds.min) / (lngBounds.max - lngBounds.min) * currentMap.width,
|
||||
top: (-locVal.lat - latBounds.min) / (latBounds.max - latBounds.min) * currentMap.height
|
||||
}
|
||||
}
|
||||
|
||||
function pxToLocation(px: Position): Location {
|
||||
return {
|
||||
lat: -(px.top / currentMap.height * (latBounds.max - latBounds.min) + latBounds.min),
|
||||
lng: px.left / currentMap.width * (lngBounds.max - lngBounds.min) + lngBounds.min
|
||||
}
|
||||
}
|
||||
|
||||
function mouseToLocation(event: MouseEvent, rect: DOMRect): Location {
|
||||
const px = mouseToPx(event, rect);
|
||||
return pxToLocation(px);
|
||||
}
|
||||
|
||||
function mouseToPx(event: MouseEvent, rect: DOMRect): Position {
|
||||
return {
|
||||
left: (event.clientX - rect.left),
|
||||
top: (event.clientY - rect.top)
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
function wrapValue(n: number, m: number) {
|
||||
return ((n % m) + m) % m;
|
||||
}
|
||||
|
||||
export function updatePinPositions(offsetX: number, offsetY: number, tileWidth: number, tileHeight: number) {
|
||||
if (Pins == undefined || Pins == null) return;
|
||||
|
||||
currentMap.width = tileWidth;
|
||||
currentMap.height = tileHeight;
|
||||
currentMap.xOffset = offsetX;
|
||||
currentMap.yOffset = offsetY;
|
||||
|
||||
for (const pin of Pins) {
|
||||
if (pin == selectedPin) continue;
|
||||
|
||||
const px = locationToPx(pin.location);
|
||||
|
||||
const formattedPx = {
|
||||
left: `${wrapValue(px.left + offsetX, tileWidth)}px`,
|
||||
top: `${wrapValue(px.top + offsetY, tileHeight)}px`
|
||||
}
|
||||
|
||||
pin.element.style.left = formattedPx.left;
|
||||
pin.element.style.top = formattedPx.top;
|
||||
}
|
||||
}
|
||||
|
||||
var timeOut: number | null = null;
|
||||
|
||||
export function startTimeOut(time: number, callback: () => void){
|
||||
clearTimeOut();
|
||||
timeOut = window.setTimeout(() => { callback(); }, time);
|
||||
}
|
||||
|
||||
export function clearTimeOut(){
|
||||
if (timeOut !== null) {
|
||||
window.clearTimeout(timeOut);
|
||||
timeOut = null;
|
||||
}
|
||||
}
|
||||
|
||||
function initPinActions(pin: Pin, app: App, component: Component) {
|
||||
pin.element.addEventListener("pointerenter", (e) => {
|
||||
if (selectedPin || disablePreviews) return;
|
||||
startTimeOut(100, () => {showCustomPreview(pin, app, component, e)} );
|
||||
});
|
||||
|
||||
// Close custom preview on mouse leave
|
||||
pin.element.addEventListener("pointerleave", (e) => {
|
||||
startTimeOut(750, () => {
|
||||
hideCustomPreview();
|
||||
});
|
||||
});
|
||||
|
||||
pin.element.addEventListener("pointerdown", (e) => {
|
||||
if (selectedPin) return; // already moving a pin, ignore new pointerdown events until current pin is released
|
||||
selectedPin = pin;
|
||||
|
||||
hideCustomPreview();
|
||||
startTimeOut(100, () => {
|
||||
cancelMapPanning();
|
||||
enablePinDrag = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
|
||||
127
src/previewInteractions.ts
Normal file
127
src/previewInteractions.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { Pin, clearTimeOut } from "./pinInteractions";
|
||||
import { App, Component, MarkdownRenderer, TFile } from "obsidian";
|
||||
|
||||
var currentPreview: HTMLElement | null = null;
|
||||
|
||||
export async function showCustomPreview(
|
||||
pin: Pin,
|
||||
app: App,
|
||||
component: Component,
|
||||
event: MouseEvent
|
||||
) {
|
||||
if (!currentPreview) {
|
||||
currentPreview = document.body.createDiv({ cls: "fantasy-map-hover-preview" });
|
||||
attachPreviewHoverHandlers();
|
||||
}
|
||||
|
||||
const container = currentPreview;
|
||||
container.empty();
|
||||
|
||||
const headerEl = container.createDiv({ cls: "fm-hover-header" });
|
||||
|
||||
const titleLink = headerEl.createEl("a", {
|
||||
cls: "internal-link fm-hover-title",
|
||||
text: pin.note.basename,
|
||||
});
|
||||
titleLink.setAttr("href", pin.note.path);
|
||||
titleLink.dataset.href = app.metadataCache.fileToLinktext(pin.note, pin.note.path);
|
||||
titleLink.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
app.workspace.openLinkText(pin.note.path, pin.note.path, e.ctrlKey || e.metaKey);
|
||||
};
|
||||
|
||||
const contentEl = container.createDiv({ cls: "fm-hover-content" });
|
||||
const fileText = await app.vault.read(pin.note);
|
||||
const body = stripFrontmatter(fileText);
|
||||
|
||||
await MarkdownRenderer.render(
|
||||
app,
|
||||
body,
|
||||
contentEl,
|
||||
pin.note.path,
|
||||
component
|
||||
);
|
||||
|
||||
wirePreviewLinks(contentEl, app, pin.note.path);
|
||||
|
||||
const rect = pin.element.getBoundingClientRect();
|
||||
container.style.position = "fixed";
|
||||
container.style.top = `${rect.bottom + 8}px`;
|
||||
container.style.left = `${rect.left}px`;
|
||||
container.addClass("is-visible");
|
||||
}
|
||||
|
||||
export function hideCustomPreview() {
|
||||
if (currentPreview) {
|
||||
currentPreview.removeClass("is-visible");
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyCustomPreview() {
|
||||
if (currentPreview) {
|
||||
currentPreview.remove();
|
||||
currentPreview = null;
|
||||
}
|
||||
}
|
||||
|
||||
// helper: remove YAML frontmatter if present
|
||||
function stripFrontmatter(text: string): string {
|
||||
if (text.startsWith("---")) {
|
||||
const end = text.indexOf("\n---", 3);
|
||||
if (end !== -1) return text.slice(end + 4);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function attachPreviewHoverHandlers() {
|
||||
if (!currentPreview) return;
|
||||
|
||||
currentPreview.onmouseenter = () => {
|
||||
clearTimeOut();
|
||||
};
|
||||
|
||||
currentPreview.onmouseleave = () => {
|
||||
hideCustomPreview();
|
||||
};
|
||||
}
|
||||
|
||||
function wirePreviewLinks(container: HTMLElement, app: App, sourcePath: string) {
|
||||
const internalLinks = container.querySelectorAll("a.internal-link");
|
||||
|
||||
internalLinks.forEach((linkEl) => {
|
||||
const link = linkEl as HTMLAnchorElement;
|
||||
|
||||
const linktext =
|
||||
link.dataset.href ||
|
||||
link.getAttribute("href") ||
|
||||
link.textContent?.trim();
|
||||
|
||||
if (!linktext) return;
|
||||
|
||||
link.onclick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
app.workspace.openLinkText(
|
||||
linktext,
|
||||
sourcePath,
|
||||
e.ctrlKey || e.metaKey
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const externalLinks = container.querySelectorAll("a.external-link");
|
||||
|
||||
externalLinks.forEach((linkEl) => {
|
||||
const link = linkEl as HTMLAnchorElement;
|
||||
const href = link.href;
|
||||
if (!href) return;
|
||||
|
||||
link.onclick = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
});
|
||||
}
|
||||
87
src/settings.ts
Normal file
87
src/settings.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import {App, PluginSettingTab, Setting} from "obsidian";
|
||||
import FantasyMap from "./main";
|
||||
import {fantasyMapCopyToClipboardString} from "./paramaters";
|
||||
|
||||
export interface FantasyMapSettings {
|
||||
defaultUnitOfMeasurement: string; // Metric or Imperial
|
||||
defaultZoomIncrement: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: FantasyMapSettings = {
|
||||
defaultUnitOfMeasurement: "Metric",
|
||||
defaultZoomIncrement: 1
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
export class FantasyMapSettingTab extends PluginSettingTab {
|
||||
plugin: FantasyMap;
|
||||
|
||||
constructor(app: App, plugin: FantasyMap) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const {containerEl} = this;
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
/*new Setting(containerEl)
|
||||
.setName('Default Unit of Measurement')
|
||||
.setDesc('Choose the default unit of measurement for the map.')
|
||||
.addDropdown(dropdown =>
|
||||
dropdown
|
||||
.addOption("Metric", "Metric")
|
||||
.addOption("Imperial", "Imperial")
|
||||
.setValue(this.plugin.settings.defaultUnitOfMeasurement)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.defaultUnitOfMeasurement = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));*/
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Default Zoom Increment')
|
||||
.setDesc('Set the default zoom increment for the map (e.g. 1 means each zoom step will increase/decrease the zoom level by 1).')
|
||||
.addText(text =>
|
||||
text
|
||||
.setPlaceholder('Enter a number (e.g. 1)')
|
||||
.setValue(this.plugin.settings.defaultZoomIncrement.toString())
|
||||
.onChange(async (value) => {
|
||||
const num = Number(value);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
this.plugin.settings.defaultZoomIncrement = num;
|
||||
await this.plugin.saveSettings();
|
||||
} else {
|
||||
// @ts-ignore
|
||||
new Notice('Please enter a valid positive number for the zoom increment.');
|
||||
}
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Template code block")
|
||||
.setDesc("Copy this into a note to start a Fantasy-Map block.")
|
||||
.addTextArea(text => {
|
||||
text.setValue(fantasyMapCopyToClipboardString);
|
||||
text.setDisabled(true);
|
||||
text.inputEl.rows = 11; // adjust to fit your block
|
||||
text.inputEl.style.width = "100%";
|
||||
text.inputEl.style.fontFamily = "var(--font-monospace)";
|
||||
})
|
||||
.addButton(button => {
|
||||
button
|
||||
.setButtonText("Copy")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fantasyMapCopyToClipboardString);
|
||||
// @ts-ignore
|
||||
new Notice("Fantasy-Map block copied");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
// @ts-ignore
|
||||
new Notice("Failed to copy block");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
234
styles.css
Normal file
234
styles.css
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/* region map layout */
|
||||
|
||||
.fantasy-map-container {
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fantasy-map-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* background layer that tiles seamlessly */
|
||||
.fantasy-map-background {
|
||||
position: relative;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-position: 0 0;
|
||||
background-repeat: repeat;
|
||||
transform: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* tiles container and individual tiles */
|
||||
.fantasy-map-tiles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.fm-tile {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-position: 0 0;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fantasy-map-container,
|
||||
.fantasy-map-background,
|
||||
.fantasy-map-tiles,
|
||||
.fm-tile {
|
||||
/* prevent image selection / text selection */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
/* prevent native drag image ghost */
|
||||
-webkit-user-drag: none;
|
||||
-khtml-user-drag: none;
|
||||
-moz-user-drag: none;
|
||||
user-drag: none;
|
||||
|
||||
/* hint that this area is for panning */
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.fantasy-map-container:active,
|
||||
.fantasy-map-background:active,
|
||||
.fantasy-map-tiles:active,
|
||||
.fm-tile:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
/* endregion */
|
||||
|
||||
/* region toolbar */
|
||||
|
||||
.fantasy-map-toolbar {
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 4px;
|
||||
display: flex; /* ensure flexbox */
|
||||
flex-direction: row;
|
||||
gap: 5px; /* 5px space between children */
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 5px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.fantasy-map-toolbar button {
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fantasy-map-toolbar input.fm-zoom-step {
|
||||
width: 4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* base state: toolbar hidden */
|
||||
.fantasy-map-toolbar {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
/* when hovering over the map container, show it */
|
||||
.fantasy-map-container:hover .fantasy-map-toolbar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* endregion */
|
||||
|
||||
/* region pins */
|
||||
|
||||
.map-pin {
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
overflow: visible;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.map-pin-icon {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.map-pin-icon svg {
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
transform-origin: center bottom;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 120ms ease, filter 120ms ease;
|
||||
color: var(--interactive-normal);
|
||||
}
|
||||
|
||||
.map-pin-icon svg:hover {
|
||||
cursor: pointer;
|
||||
transform: translate(-100%, -10%) scale(1.1);
|
||||
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
|
||||
color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
/* endregion */
|
||||
|
||||
/* region page previews */
|
||||
|
||||
.fantasy-map-hover-preview {
|
||||
position: fixed;
|
||||
width: 520px; /* or keep max-width if you prefer */
|
||||
max-height: 320px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-secondary);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
color: var(--text-normal);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
z-index: 9999;
|
||||
overflow: hidden; /* clip anything outside */
|
||||
}
|
||||
|
||||
.fantasy-map-hover-preview.is-visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.fantasy-map-hover-preview .fm-hover-title {
|
||||
font-size: var(--h1-size); /* same as H1 */
|
||||
font-weight: var(--h1-weight, 700); /* fallback if var not defined */
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Optional: make internal links in the preview clickable */
|
||||
.fantasy-map-hover-preview a.internal-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* header stays fixed at top */
|
||||
.fm-hover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.fm-hover-open {
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.fm-hover-open:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fm-hover-content {
|
||||
max-height: 260px; /* 320 - header & padding */
|
||||
overflow-y: auto; /* vertical scrollbar when needed */
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
padding-right: 4px; /* room for scrollbar */
|
||||
}
|
||||
|
||||
.fantasy-map-hover-preview .fm-hover-content img,
|
||||
.fantasy-map-hover-preview .fm-hover-content svg,
|
||||
.fantasy-map-hover-preview .fm-hover-content video,
|
||||
.fantasy-map-hover-preview .fm-hover-content .internal-embed img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fantasy-map-hover-preview .fm-hover-content .internal-embed,
|
||||
.fantasy-map-hover-preview .fm-hover-content .image-embed {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fantasy-map-hover-preview .fm-hover-content p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* endregion */
|
||||
|
||||
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitReturns": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"strictBindCallApply": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const targetVersion = process.env.npm_package_version;
|
||||
|
||||
// read minAppVersion from manifest.json and bump version to target version
|
||||
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||
const { minAppVersion } = manifest;
|
||||
manifest.version = targetVersion;
|
||||
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||
|
||||
// update versions.json with target version and minAppVersion from manifest.json
|
||||
// but only if the target version is not already in versions.json
|
||||
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||
if (!Object.values(versions).includes(minAppVersion)) {
|
||||
versions[targetVersion] = minAppVersion;
|
||||
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||
}
|
||||
3
versions.json
Normal file
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue