@@ -6087,6 +6192,15 @@
browser
+
+ Default map
+
+
+
Maps are saved in .map format, that can be loaded back via the Load in menu. There is no way to
restore the progress if file is lost. Please keep old save files on your machine or cloud storage as backups.
@@ -8118,7 +8232,7 @@
-
+
@@ -8141,7 +8255,7 @@
-
+
@@ -8163,10 +8277,13 @@
-
+
+
+
+
diff --git a/main.js b/main.js
index 848ff3c5..4f4bdb63 100644
--- a/main.js
+++ b/main.js
@@ -291,6 +291,22 @@ async function checkLoadParameters() {
return;
}
+ // check if there is a default map saved to indexedDB
+ if (byId("onloadBehavior").value === "default") {
+ try {
+ const blob = await ldb.get("defaultMap");
+ if (blob) {
+ WARN && console.warn("Loading default map");
+ uploadMap(blob);
+ return;
+ } else {
+ WARN && console.warn("No default map set, generating random map");
+ }
+ } catch (error) {
+ ERROR && console.error(error);
+ }
+ }
+
// check if there is a map saved to indexedDB
if (byId("onloadBehavior").value === "lastSaved") {
try {
diff --git a/modules/io/obsidian-bridge.js b/modules/io/obsidian-bridge.js
new file mode 100644
index 00000000..1d8325fc
--- /dev/null
+++ b/modules/io/obsidian-bridge.js
@@ -0,0 +1,416 @@
+"use strict";
+
+// Obsidian Vault Integration for Fantasy Map Generator
+// Uses Obsidian Local REST API plugin
+// https://github.com/coddingtonbear/obsidian-local-rest-api
+
+const ObsidianBridge = (() => {
+ // Configuration
+ const config = {
+ apiUrl: "http://127.0.0.1:27123",
+ apiKey: "", // Set via UI
+ enabled: false,
+ vaultName: ""
+ };
+
+ // Initialize from localStorage
+ function init() {
+ const stored = localStorage.getItem("obsidianConfig");
+ if (stored) {
+ try {
+ const parsed = JSON.parse(stored);
+ Object.assign(config, parsed);
+ } catch (error) {
+ ERROR && console.error("Failed to load Obsidian config:", error);
+ }
+ }
+ }
+
+ // Save configuration
+ function saveConfig() {
+ localStorage.setItem("obsidianConfig", JSON.stringify(config));
+ }
+
+ // Test connection to Obsidian
+ async function testConnection() {
+ if (!config.apiKey) {
+ throw new Error("API key not set. Please configure in Options.");
+ }
+
+ try {
+ const response = await fetch(`${config.apiUrl}/`, {
+ headers: {
+ Authorization: `Bearer ${config.apiKey}`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Connection failed: ${response.status}`);
+ }
+
+ const data = await response.json();
+ INFO && console.log("Obsidian connection successful:", data);
+ config.enabled = true;
+ saveConfig();
+ return true;
+ } catch (error) {
+ ERROR && console.error("Obsidian connection failed:", error);
+ config.enabled = false;
+ saveConfig();
+ throw error;
+ }
+ }
+
+ // Get all markdown files from vault
+ async function getVaultFiles() {
+ if (!config.enabled) {
+ throw new Error("Obsidian not connected");
+ }
+
+ try {
+ const response = await fetch(`${config.apiUrl}/vault/`, {
+ headers: {
+ Authorization: `Bearer ${config.apiKey}`
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch vault files: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return data.files || [];
+ } catch (error) {
+ ERROR && console.error("Failed to get vault files:", error);
+ throw error;
+ }
+ }
+
+ // Get note content by path
+ async function getNote(notePath) {
+ if (!config.enabled) {
+ throw new Error("Obsidian not connected");
+ }
+
+ try {
+ const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
+ headers: {
+ Authorization: `Bearer ${config.apiKey}`,
+ Accept: "text/markdown"
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch note: ${response.status}`);
+ }
+
+ return await response.text();
+ } catch (error) {
+ ERROR && console.error("Failed to get note:", error);
+ throw error;
+ }
+ }
+
+ // Update note content
+ async function updateNote(notePath, content) {
+ if (!config.enabled) {
+ throw new Error("Obsidian not connected");
+ }
+
+ try {
+ const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${config.apiKey}`,
+ "Content-Type": "text/markdown"
+ },
+ body: content
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to update note: ${response.status}`);
+ }
+
+ INFO && console.log("Note updated successfully:", notePath);
+ return true;
+ } catch (error) {
+ ERROR && console.error("Failed to update note:", error);
+ throw error;
+ }
+ }
+
+ // Create new note
+ async function createNote(notePath, content) {
+ if (!config.enabled) {
+ throw new Error("Obsidian not connected");
+ }
+
+ try {
+ const response = await fetch(`${config.apiUrl}/vault/${encodeURIComponent(notePath)}`, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${config.apiKey}`,
+ "Content-Type": "text/markdown"
+ },
+ body: content
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to create note: ${response.status}`);
+ }
+
+ INFO && console.log("Note created successfully:", notePath);
+ return true;
+ } catch (error) {
+ ERROR && console.error("Failed to create note:", error);
+ throw error;
+ }
+ }
+
+ // Parse YAML frontmatter from markdown content
+ function parseFrontmatter(content) {
+ const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---/;
+ const match = content.match(frontmatterRegex);
+
+ if (!match) {
+ return {frontmatter: {}, content};
+ }
+
+ const frontmatterText = match[1];
+ const bodyContent = content.slice(match[0].length).trim();
+
+ // Simple YAML parser (handles basic key-value pairs and nested objects)
+ const frontmatter = {};
+ const lines = frontmatterText.split("\n");
+ let currentKey = null;
+ let currentObj = frontmatter;
+ let indentLevel = 0;
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith("#")) continue;
+
+ const indent = line.search(/\S/);
+ const colonIndex = trimmed.indexOf(":");
+
+ if (colonIndex === -1) continue;
+
+ const key = trimmed.slice(0, colonIndex).trim();
+ const value = trimmed.slice(colonIndex + 1).trim();
+
+ if (indent === 0) {
+ currentObj = frontmatter;
+ currentKey = key;
+
+ if (value) {
+ // Simple value
+ frontmatter[key] = parseValue(value);
+ } else {
+ // Nested object or array
+ frontmatter[key] = {};
+ currentObj = frontmatter[key];
+ }
+ } else if (currentObj && key) {
+ // Nested property
+ currentObj[key] = parseValue(value);
+ }
+ }
+
+ return {frontmatter, content: bodyContent};
+ }
+
+ // Parse YAML value (handle strings, numbers, arrays)
+ function parseValue(value) {
+ if (!value) return null;
+
+ // Array
+ if (value.startsWith("[") && value.endsWith("]")) {
+ return value
+ .slice(1, -1)
+ .split(",")
+ .map(v => v.trim())
+ .filter(v => v);
+ }
+
+ // Number
+ if (!isNaN(value) && value !== "") {
+ return parseFloat(value);
+ }
+
+ // Boolean
+ if (value === "true") return true;
+ if (value === "false") return false;
+
+ // String (remove quotes if present)
+ return value.replace(/^["']|["']$/g, "");
+ }
+
+ // Calculate distance between two coordinates
+ function calculateDistance(x1, y1, x2, y2) {
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ return Math.sqrt(dx * dx + dy * dy);
+ }
+
+ // Find notes by coordinates
+ async function findNotesByCoordinates(x, y, limit = 8) {
+ TIME && console.time("findNotesByCoordinates");
+
+ try {
+ const files = await getVaultFiles();
+ const mdFiles = files.filter(f => f.endsWith(".md"));
+
+ const matches = [];
+
+ for (const filePath of mdFiles) {
+ try {
+ const content = await getNote(filePath);
+ const {frontmatter} = parseFrontmatter(content);
+
+ // Check for coordinates in frontmatter
+ let noteX, noteY;
+
+ // Support various coordinate formats
+ if (frontmatter.coordinates) {
+ noteX = frontmatter.coordinates.x || frontmatter.coordinates.X;
+ noteY = frontmatter.coordinates.y || frontmatter.coordinates.Y;
+ } else {
+ noteX = frontmatter.x || frontmatter.X;
+ noteY = frontmatter.y || frontmatter.Y;
+ }
+
+ if (noteX !== undefined && noteY !== undefined) {
+ const distance = calculateDistance(x, y, noteX, noteY);
+
+ matches.push({
+ path: filePath,
+ name: filePath.replace(/\.md$/, "").split("/").pop(),
+ frontmatter,
+ distance,
+ coordinates: {x: noteX, y: noteY}
+ });
+ }
+ } catch (error) {
+ // Skip files that can't be read
+ DEBUG && console.debug("Skipping file:", filePath, error);
+ }
+ }
+
+ // Sort by distance and return top matches
+ matches.sort((a, b) => a.distance - b.distance);
+ const results = matches.slice(0, limit);
+
+ TIME && console.timeEnd("findNotesByCoordinates");
+ INFO && console.log(`Found ${results.length} nearby notes for (${x}, ${y})`);
+
+ return results;
+ } catch (error) {
+ ERROR && console.error("Failed to find notes by coordinates:", error);
+ TIME && console.timeEnd("findNotesByCoordinates");
+ throw error;
+ }
+ }
+
+ // Find note by FMG ID in frontmatter
+ async function findNoteByFmgId(fmgId) {
+ try {
+ const files = await getVaultFiles();
+ const mdFiles = files.filter(f => f.endsWith(".md"));
+
+ for (const filePath of mdFiles) {
+ try {
+ const content = await getNote(filePath);
+ const {frontmatter} = parseFrontmatter(content);
+
+ if (frontmatter["fmg-id"] === fmgId || frontmatter.fmgId === fmgId) {
+ return {
+ path: filePath,
+ name: filePath.replace(/\.md$/, "").split("/").pop(),
+ content,
+ frontmatter
+ };
+ }
+ } catch (error) {
+ DEBUG && console.debug("Skipping file:", filePath, error);
+ }
+ }
+
+ return null;
+ } catch (error) {
+ ERROR && console.error("Failed to find note by FMG ID:", error);
+ throw error;
+ }
+ }
+
+ // Generate note template for FMG element
+ function generateNoteTemplate(element, type) {
+ const {x, y} = element;
+ const lat = pack.cells.lat?.[element.cell] || 0;
+ const lon = pack.cells.lon?.[element.cell] || 0;
+
+ const frontmatter = {
+ "fmg-id": element.id || `${type}${element.i}`,
+ "fmg-type": type,
+ coordinates: {x, y, lat, lon},
+ tags: [type],
+ created: new Date().toISOString()
+ };
+
+ if (element.name) {
+ frontmatter.aliases = [element.name];
+ }
+
+ const yaml = Object.entries(frontmatter)
+ .map(([key, value]) => {
+ if (typeof value === "object" && !Array.isArray(value)) {
+ const nested = Object.entries(value)
+ .map(([k, v]) => ` ${k}: ${v}`)
+ .join("\n");
+ return `${key}:\n${nested}`;
+ } else if (Array.isArray(value)) {
+ return `${key}:\n - ${value.join("\n - ")}`;
+ }
+ return `${key}: ${value}`;
+ })
+ .join("\n");
+
+ const title = element.name || `${type} ${element.i}`;
+
+ return `---
+${yaml}
+---
+
+# ${title}
+
+*This note was created from Fantasy Map Generator*
+
+## Description
+
+Add your lore here...
+
+## History
+
+## Notable Features
+
+## Related
+`;
+ }
+
+ return {
+ init,
+ config,
+ saveConfig,
+ testConnection,
+ getVaultFiles,
+ getNote,
+ updateNote,
+ createNote,
+ parseFrontmatter,
+ findNotesByCoordinates,
+ findNoteByFmgId,
+ generateNoteTemplate
+ };
+})();
+
+// Initialize on load
+ObsidianBridge.init();
diff --git a/modules/io/save.js b/modules/io/save.js
index 304fef59..7a9236c7 100644
--- a/modules/io/save.js
+++ b/modules/io/save.js
@@ -167,6 +167,32 @@ async function saveToStorage(mapData, showTip = false) {
showTip && tip("Map is saved to the browser storage", false, "success");
}
+// save current map as the default map
+async function saveAsDefaultMap() {
+ if (customization) return tip("Map cannot be saved in EDIT mode, please complete the edit and retry", false, "error");
+
+ try {
+ const mapData = prepareMapData();
+ const blob = new Blob([mapData], {type: "text/plain"});
+ await ldb.set("defaultMap", blob);
+ tip("Map is set as default and will open on load", true, "success", 5000);
+ } catch (error) {
+ ERROR && console.error(error);
+ tip("Failed to set default map", true, "error", 3000);
+ }
+}
+
+// clear the default map setting
+async function clearDefaultMap() {
+ try {
+ await ldb.set("defaultMap", null);
+ tip("Default map cleared", false, "success", 2000);
+ } catch (error) {
+ ERROR && console.error(error);
+ tip("Failed to clear default map", false, "error", 2000);
+ }
+}
+
// download map file
function saveToMachine(mapData, filename) {
const blob = new Blob([mapData], {type: "text/plain"});
diff --git a/modules/ui/obsidian-config.js b/modules/ui/obsidian-config.js
new file mode 100644
index 00000000..f5db2ae1
--- /dev/null
+++ b/modules/ui/obsidian-config.js
@@ -0,0 +1,73 @@
+"use strict";
+
+// Obsidian Configuration UI
+
+function openObsidianConfig() {
+ // Load current config
+ const {apiUrl, apiKey, vaultName, enabled} = ObsidianBridge.config;
+
+ byId("obsidianApiUrl").value = apiUrl || "http://127.0.0.1:27123";
+ byId("obsidianApiKey").value = apiKey || "";
+ byId("obsidianVaultName").value = vaultName || "";
+
+ updateObsidianStatus(enabled);
+
+ $("#obsidianConfig").dialog({
+ title: "Obsidian Vault Configuration",
+ width: "600px",
+ position: {my: "center", at: "center", of: "svg"}
+ });
+}
+
+function updateObsidianStatus(enabled) {
+ const statusEl = byId("obsidianStatus");
+ if (enabled) {
+ statusEl.textContent = "✅ Connected";
+ statusEl.style.color = "#2ecc71";
+ } else {
+ statusEl.textContent = "❌ Not connected";
+ statusEl.style.color = "#e74c3c";
+ }
+}
+
+async function testObsidianConnection() {
+ const apiUrl = byId("obsidianApiUrl").value.trim();
+ const apiKey = byId("obsidianApiKey").value.trim();
+
+ if (!apiUrl || !apiKey) {
+ tip("Please enter both API URL and API Key", false, "error", 3000);
+ return;
+ }
+
+ // Temporarily set config for testing
+ Object.assign(ObsidianBridge.config, {apiUrl, apiKey});
+
+ byId("obsidianStatus").textContent = "Testing connection...";
+ byId("obsidianStatus").style.color = "#f39c12";
+
+ try {
+ await ObsidianBridge.testConnection();
+ updateObsidianStatus(true);
+ tip("Successfully connected to Obsidian!", true, "success", 3000);
+ } catch (error) {
+ updateObsidianStatus(false);
+ tip("Connection failed: " + error.message, true, "error", 5000);
+ }
+}
+
+function saveObsidianConfig() {
+ const apiUrl = byId("obsidianApiUrl").value.trim();
+ const apiKey = byId("obsidianApiKey").value.trim();
+ const vaultName = byId("obsidianVaultName").value.trim();
+
+ if (!apiUrl || !apiKey) {
+ tip("Please enter both API URL and API Key", false, "error", 3000);
+ return;
+ }
+
+ Object.assign(ObsidianBridge.config, {apiUrl, apiKey, vaultName});
+ ObsidianBridge.saveConfig();
+
+ $("#obsidianConfig").dialog("close");
+ tip("Obsidian configuration saved", true, "success", 2000);
+}
diff --git a/modules/ui/obsidian-notes-editor.js b/modules/ui/obsidian-notes-editor.js
new file mode 100644
index 00000000..78fa133e
--- /dev/null
+++ b/modules/ui/obsidian-notes-editor.js
@@ -0,0 +1,358 @@
+"use strict";
+
+// Modern Markdown Notes Editor with Obsidian Integration
+
+function editObsidianNote(elementId, elementType, coordinates) {
+ const {x, y} = coordinates;
+
+ // Show loading dialog
+ showLoadingDialog();
+
+ // Try to find note by FMG ID first, then by coordinates
+ findOrCreateNote(elementId, elementType, coordinates)
+ .then(noteData => {
+ showMarkdownEditor(noteData, elementType);
+ })
+ .catch(error => {
+ ERROR && console.error("Failed to load note:", error);
+ tip("Failed to load Obsidian note: " + error.message, true, "error", 5000);
+ closeDialogs("#obsidianNoteLoading");
+ });
+}
+
+async function findOrCreateNote(elementId, elementType, coordinates) {
+ const {x, y} = coordinates;
+
+ // First try to find by FMG ID
+ let note = await ObsidianBridge.findNoteByFmgId(elementId);
+
+ if (note) {
+ INFO && console.log("Found note by FMG ID:", note.path);
+ return note;
+ }
+
+ // Find by coordinates
+ const matches = await ObsidianBridge.findNotesByCoordinates(x, y, 8);
+
+ closeDialogs("#obsidianNoteLoading");
+
+ if (matches.length === 0) {
+ // No matches - offer to create new note
+ return await promptCreateNewNote(elementId, elementType, coordinates);
+ }
+
+ if (matches.length === 1) {
+ // Single match - load it
+ const match = matches[0];
+ const content = await ObsidianBridge.getNote(match.path);
+ return {
+ path: match.path,
+ name: match.name,
+ content,
+ frontmatter: match.frontmatter
+ };
+ }
+
+ // Multiple matches - show selection dialog
+ return await showNoteSelectionDialog(matches, elementId, elementType, coordinates);
+}
+
+function showLoadingDialog() {
+ alertMessage.innerHTML = `
+
+
+
Searching Obsidian vault for matching notes...
+
+ `;
+
+ $("#alert").dialog({
+ title: "Loading from Obsidian",
+ width: "400px",
+ closeOnEscape: false,
+ buttons: {},
+ dialogClass: "no-close",
+ position: {my: "center", at: "center", of: "svg"}
+ });
+}
+
+async function showNoteSelectionDialog(matches, elementId, elementType, coordinates) {
+ return new Promise((resolve, reject) => {
+ const matchList = matches
+ .map(
+ (match, index) => `
+
+
${match.name}
+
+ Distance: ${match.distance.toFixed(1)} units
+ Coordinates: (${match.coordinates.x}, ${match.coordinates.y})
+ Path: ${match.path}
+
+
+ `
+ )
+ .join("");
+
+ alertMessage.innerHTML = `
+
+
Found ${matches.length} notes near this location. Select one:
+ ${matchList}
+
+ `;
+
+ $("#alert").dialog({
+ title: "Select Obsidian Note",
+ width: "600px",
+ buttons: {
+ "Create New": async function () {
+ $(this).dialog("close");
+ try {
+ const newNote = await promptCreateNewNote(elementId, elementType, coordinates);
+ resolve(newNote);
+ } catch (error) {
+ reject(error);
+ }
+ },
+ Cancel: function () {
+ $(this).dialog("close");
+ reject(new Error("Cancelled"));
+ }
+ },
+ position: {my: "center", at: "center", of: "svg"}
+ });
+
+ // Add click handlers to matches
+ document.querySelectorAll(".note-match").forEach((el, index) => {
+ el.addEventListener("click", async () => {
+ $("#alert").dialog("close");
+ try {
+ const match = matches[index];
+ const content = await ObsidianBridge.getNote(match.path);
+ resolve({
+ path: match.path,
+ name: match.name,
+ content,
+ frontmatter: match.frontmatter
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+ });
+ });
+}
+
+async function promptCreateNewNote(elementId, elementType, coordinates) {
+ return new Promise((resolve, reject) => {
+ const element = getElementData(elementId, elementType);
+ const suggestedName = element.name || `${elementType}-${element.i}`;
+
+ alertMessage.innerHTML = `
+
No matching notes found. Create a new note in your Obsidian vault?
+
+
+
+
+
+
+
+
+ `;
+
+ $("#alert").dialog({
+ title: "Create New Note",
+ width: "500px",
+ buttons: {
+ Create: async function () {
+ const name = byId("newNoteName").value.trim();
+ const folder = byId("newNotePath").value.trim();
+
+ if (!name) {
+ tip("Please enter a note name", false, "error");
+ return;
+ }
+
+ const notePath = folder ? `${folder}/${name}.md` : `${name}.md`;
+
+ $(this).dialog("close");
+
+ try {
+ const template = ObsidianBridge.generateNoteTemplate(element, elementType);
+ await ObsidianBridge.createNote(notePath, template);
+
+ const {frontmatter} = ObsidianBridge.parseFrontmatter(template);
+
+ resolve({
+ path: notePath,
+ name,
+ content: template,
+ frontmatter,
+ isNew: true
+ });
+ } catch (error) {
+ reject(error);
+ }
+ },
+ Cancel: function () {
+ $(this).dialog("close");
+ reject(new Error("Cancelled"));
+ }
+ },
+ position: {my: "center", at: "center", of: "svg"}
+ });
+ });
+}
+
+function getElementData(elementId, elementType) {
+ // Extract element data based on type
+ if (elementType === "burg") {
+ const burgId = parseInt(elementId.replace("burg", ""));
+ return pack.burgs[burgId];
+ } else if (elementType === "marker") {
+ const markerId = parseInt(elementId.replace("marker", ""));
+ return pack.markers[markerId];
+ } else {
+ // Generic element
+ const el = document.getElementById(elementId);
+ return {
+ id: elementId,
+ name: elementId,
+ x: parseFloat(el?.getAttribute("cx") || 0),
+ y: parseFloat(el?.getAttribute("cy") || 0)
+ };
+ }
+}
+
+function showMarkdownEditor(noteData, elementType) {
+ const {path, name, content, frontmatter, isNew} = noteData;
+
+ // Extract frontmatter and body
+ const {content: bodyContent} = ObsidianBridge.parseFrontmatter(content);
+
+ // Set up the dialog
+ byId("obsidianNotePath").textContent = path;
+ byId("obsidianNoteName").value = name;
+ byId("obsidianMarkdownEditor").value = content;
+ byId("obsidianMarkdownPreview").innerHTML = renderMarkdown(bodyContent);
+
+ // Store current note data
+ showMarkdownEditor.currentNote = noteData;
+ showMarkdownEditor.originalContent = content;
+
+ $("#obsidianNotesEditor").dialog({
+ title: `Obsidian Note: ${name}`,
+ width: Math.min(svgWidth * 0.9, 1200),
+ height: svgHeight * 0.85,
+ position: {my: "center", at: "center", of: "svg"},
+ close: () => {
+ showMarkdownEditor.currentNote = null;
+ showMarkdownEditor.originalContent = null;
+ }
+ });
+
+ // Update preview on edit
+ updateMarkdownPreview();
+
+ if (isNew) {
+ tip("New note created in Obsidian vault", true, "success", 3000);
+ }
+}
+
+function updateMarkdownPreview() {
+ const content = byId("obsidianMarkdownEditor").value;
+ const {content: bodyContent} = ObsidianBridge.parseFrontmatter(content);
+ byId("obsidianMarkdownPreview").innerHTML = renderMarkdown(bodyContent);
+}
+
+function renderMarkdown(markdown) {
+ // Simple Markdown renderer (will be replaced with marked.js)
+ let html = markdown;
+
+ // Headers
+ html = html.replace(/^### (.*$)/gim, "
$1
");
+ html = html.replace(/^## (.*$)/gim, "
$1
");
+ html = html.replace(/^# (.*$)/gim, "
$1
");
+
+ // Bold
+ html = html.replace(/\*\*(.*?)\*\*/g, "
$1");
+ html = html.replace(/\_\_(.*?)\_\_/g, "
$1");
+
+ // Italic
+ html = html.replace(/\*(.*?)\*/g, "
$1");
+ html = html.replace(/\_(.*?)\_/g, "
$1");
+
+ // Links
+ html = html.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '
$1');
+
+ // Wikilinks [[Page]]
+ html = html.replace(/\[\[([^\]]+)\]\]/g, '
$1');
+
+ // Lists
+ html = html.replace(/^\* (.*)$/gim, "
$1");
+ html = html.replace(/^\- (.*)$/gim, "
$1");
+ html = html.replace(/(
.*<\/li>)/s, "");
+
+ // Paragraphs
+ html = html.replace(/\n\n/g, "");
+ html = "
" + html + "
";
+
+ // Clean up
+ html = html.replace(/<\/p>/g, "");
+ html = html.replace(/
()/g, "$1");
+ html = html.replace(/(<\/h[1-6]>)<\/p>/g, "$1");
+
+ return html;
+}
+
+async function saveObsidianNote() {
+ if (!showMarkdownEditor.currentNote) {
+ tip("No note loaded", false, "error");
+ return;
+ }
+
+ const content = byId("obsidianMarkdownEditor").value;
+ const {path} = showMarkdownEditor.currentNote;
+
+ try {
+ await ObsidianBridge.updateNote(path, content);
+ showMarkdownEditor.originalContent = content;
+ tip("Note saved to Obsidian vault", true, "success", 2000);
+ } catch (error) {
+ ERROR && console.error("Failed to save note:", error);
+ tip("Failed to save note: " + error.message, true, "error", 5000);
+ }
+}
+
+function openInObsidian() {
+ if (!showMarkdownEditor.currentNote) return;
+
+ const {path} = showMarkdownEditor.currentNote;
+ const vaultName = ObsidianBridge.config.vaultName || "vault";
+ const obsidianUrl = `obsidian://open?vault=${encodeURIComponent(vaultName)}&file=${encodeURIComponent(path)}`;
+
+ window.open(obsidianUrl, "_blank");
+ tip("Opening in Obsidian app...", false, "success", 2000);
+}
+
+function togglePreviewMode() {
+ const editor = byId("obsidianMarkdownEditor");
+ const preview = byId("obsidianMarkdownPreview");
+ const isPreviewMode = editor.style.display === "none";
+
+ if (isPreviewMode) {
+ editor.style.display = "block";
+ preview.style.display = "none";
+ byId("togglePreview").textContent = "👁 Preview";
+ } else {
+ updateMarkdownPreview();
+ editor.style.display = "none";
+ preview.style.display = "block";
+ byId("togglePreview").textContent = "✏ Edit";
+ }
+}
diff --git a/modules/ui/units-editor.js b/modules/ui/units-editor.js
index a30e9a7d..aa63e64a 100644
--- a/modules/ui/units-editor.js
+++ b/modules/ui/units-editor.js
@@ -121,11 +121,16 @@ function editUnits() {
function addRuler() {
if (!layerIsOn("toggleRulers")) toggleRulers();
+
+ const width = Math.min(graphWidth, svgWidth);
+ const height = Math.min(graphHeight, svgHeight);
const pt = byId("map").createSVGPoint();
- (pt.x = graphWidth / 2), (pt.y = graphHeight / 4);
+ pt.x = width / 2;
+ pt.y = height / 4;
const p = pt.matrixTransform(viewbox.node().getScreenCTM().inverse());
- const dx = graphWidth / 4 / scale;
- const dy = (rulers.data.length * 40) % (graphHeight / 2);
+
+ const dx = width / 4 / scale;
+ const dy = (rulers.data.length * 40) % (height / 2);
const from = [(p.x - dx) | 0, (p.y + dy) | 0];
const to = [(p.x + dx) | 0, (p.y + dy) | 0];
rulers.create(Ruler, [from, to]).draw();
diff --git a/versioning.js b/versioning.js
index a785e90e..9cb511be 100644
--- a/versioning.js
+++ b/versioning.js
@@ -13,7 +13,9 @@
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
*/
-const VERSION = "1.108.11";
+
+const VERSION = "1.108.13";
+
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
{