ObsiViewer/src/app/features/drawings/excalidraw-io.service.ts

167 lines
4.5 KiB
TypeScript

import { Injectable } from '@angular/core';
import * as LZString from 'lz-string';
export interface ExcalidrawScene {
elements: any[];
appState?: Record<string, any>;
files?: Record<string, any>;
}
/**
* Service for parsing and serializing Excalidraw files in Obsidian format
*/
@Injectable({ providedIn: 'root' })
export class ExcalidrawIoService {
/**
* Extract front matter from markdown content
*/
extractFrontMatter(md: string): string | null {
if (!md) return null;
const match = md.match(/^---\s*\n([\s\S]*?)\n---/);
return match ? match[0] : null;
}
/**
* Parse Obsidian Excalidraw markdown format
* Extracts compressed-json block and decompresses using LZ-String
*/
parseObsidianMd(md: string): ExcalidrawScene | null {
if (!md || typeof md !== 'string') return null;
// Try to extract compressed-json block
const compressedMatch = md.match(/```\s*compressed-json\s*\n([\s\S]*?)\n```/i);
if (compressedMatch && compressedMatch[1]) {
try {
// Remove whitespace from base64 data
const compressed = compressedMatch[1].replace(/\s+/g, '');
// Decompress using LZ-String
const decompressed = LZString.decompressFromBase64(compressed);
if (!decompressed) {
console.warn('[Excalidraw] LZ-String decompression returned null');
return null;
}
// Parse JSON
const data = JSON.parse(decompressed);
return this.normalizeScene(data);
} catch (error) {
console.error('[Excalidraw] Failed to parse compressed-json:', error);
return null;
}
}
// Fallback: try to extract plain json block
const jsonMatch = md.match(/```\s*(?:excalidraw|json)\s*\n([\s\S]*?)\n```/i);
if (jsonMatch && jsonMatch[1]) {
try {
const data = JSON.parse(jsonMatch[1].trim());
return this.normalizeScene(data);
} catch (error) {
console.error('[Excalidraw] Failed to parse json block:', error);
return null;
}
}
return null;
}
/**
* Parse flat JSON format (legacy ObsiViewer format)
*/
parseFlatJson(text: string): ExcalidrawScene | null {
if (!text || typeof text !== 'string') return null;
try {
const data = JSON.parse(text);
// Validate it has the expected structure
if (data && typeof data === 'object' && Array.isArray(data.elements)) {
return this.normalizeScene(data);
}
return null;
} catch (error) {
return null;
}
}
/**
* Convert Excalidraw scene to Obsidian markdown format
*/
toObsidianMd(data: ExcalidrawScene, existingFrontMatter?: string | null): string {
// Normalize scene data
const scene = this.normalizeScene(data);
// Serialize to JSON
const json = JSON.stringify(scene);
// Compress using LZ-String
const compressed = LZString.compressToBase64(json);
// Use existing front matter or create default
const frontMatter = existingFrontMatter?.trim() || `---
excalidraw-plugin: parsed
tags: [excalidraw]
---`;
// Banner text (Obsidian standard)
const banner = `==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'`;
// Construct full markdown
return `${frontMatter}
${banner}
# Excalidraw Data
## Text Elements
%%
## Drawing
\`\`\`compressed-json
${compressed}
\`\`\`
%%`;
}
/**
* Parse Excalidraw content from any supported format
* Tries Obsidian MD format first, then falls back to flat JSON
*/
parseAny(text: string): ExcalidrawScene | null {
// Try Obsidian format first
let data = this.parseObsidianMd(text);
if (data) return data;
// Fallback to flat JSON
data = this.parseFlatJson(text);
if (data) return data;
return null;
}
/**
* Normalize scene structure
*/
private normalizeScene(data: any): ExcalidrawScene {
return {
elements: Array.isArray(data?.elements) ? data.elements : [],
appState: (data && typeof data.appState === 'object') ? data.appState : {},
files: (data && typeof data.files === 'object') ? data.files : {}
};
}
/**
* Validate Excalidraw scene structure
*/
isValidScene(data: any): boolean {
if (!data || typeof data !== 'object') return false;
if (!Array.isArray(data.elements)) return false;
return true;
}
}