initial comit

This commit is contained in:
Daniel Fiuk 2026-04-10 16:14:25 -06:00
parent c82dc0096e
commit 61fea666d4
22 changed files with 7126 additions and 0 deletions

10
.editorconfig Normal file
View 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
View 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
View 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
View file

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

251
AGENTS.md Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

29
package.json Normal file
View 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"
}
}

View 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
View 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 highquality 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}