diff --git a/manifest.json b/manifest.json index 444a70b..3a4b03c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-excalidraw-plugin", "name": "Excalidraw", - "version": "1.3.19", + "version": "1.3.20", "minAppVersion": "0.12.0", "description": "An Obsidian plugin to edit and view Excalidraw drawings", "author": "Zsolt Viczian", diff --git a/src/ExcalidrawAutomate.ts b/src/ExcalidrawAutomate.ts index a3c4dce..cdfbcef 100644 --- a/src/ExcalidrawAutomate.ts +++ b/src/ExcalidrawAutomate.ts @@ -331,7 +331,7 @@ export async function initExcalidrawAutomate(plugin: ExcalidrawPlugin) { gridSize: template?.appState?.gridSize ?? this.canvas.gridSize, files: template?.appState?.files ?? {}, } - })) + },null,"\t")) ); }, async createSVG(templatePath?:string,embedFont:boolean = false):Promise { diff --git a/src/ExcalidrawData.ts b/src/ExcalidrawData.ts index 968ea0f..00addba 100644 --- a/src/ExcalidrawData.ts +++ b/src/ExcalidrawData.ts @@ -1,541 +1,541 @@ -import { App, normalizePath, TFile } from "obsidian"; -import { - nanoid, - FRONTMATTER_KEY_CUSTOM_PREFIX, - FRONTMATTER_KEY_CUSTOM_LINK_BRACKETS, - FRONTMATTER_KEY_CUSTOM_URL_PREFIX, -} from "./constants"; -import { measureText } from "./ExcalidrawAutomate"; -import ExcalidrawPlugin from "./main"; -import { - JSON_parse -} from "./constants"; -import { TextMode } from "./ExcalidrawView"; -import { wrapText } from "./Utils"; -import { FileId } from "@zsviczian/excalidraw/types/element/types"; - - -declare module "obsidian" { - interface MetadataCache { - blockCache: { - getForFile(x:any,f:TAbstractFile):any; - } - } -} - -export const REGEX_LINK = { - //![[link|alias]] [alias](link){num} - // 1 2 3 4 5 6 7 8 9 - EXPR: /(!)?(\[\[([^|\]]+)\|?([^\]]+)?]]|\[([^\]]*)]\(([^)]*)\))(\{(\d+)\})?/g, //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/187 - getRes: (text:string):IterableIterator => { - return text.matchAll(REGEX_LINK.EXPR); - }, - isTransclusion: (parts: IteratorResult):boolean => { - return parts.value[1] ? true:false; - }, - getLink: (parts: IteratorResult):string => { - return parts.value[3] ? parts.value[3] : parts.value[6]; - }, - isWikiLink: (parts: IteratorResult):boolean => { - return parts.value[3] ? true:false; - }, - getAliasOrLink: (parts: IteratorResult):string => { - return REGEX_LINK.isWikiLink(parts) - ? (parts.value[4] ? parts.value[4] : parts.value[3]) - : (parts.value[5] ? parts.value[5] : parts.value[6]); - }, - getWrapLength: (parts: IteratorResult):number => { - return parts.value[8]; - } -} - - -export const REG_LINKINDEX_HYPERLINK = /^\w+:\/\//; - -const DRAWING_REG = /\n%%\n# Drawing\n[^`]*(```json\n)(.*)\n[^`]*```[^%]*(%%)?/gm; //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/182 -const DRAWING_REG_FALLBACK = /\n# Drawing\n(```json\n)?(.*)(```)?(%%)?/gm; -export function getJSON(data:string):[string,number] { - let res = data.matchAll(DRAWING_REG); - - //In case the user adds a text element with the contents "# Drawing\n" - let parts; - parts = res.next(); - if(parts.done) { //did not find a match - res = data.matchAll(DRAWING_REG_FALLBACK); - parts = res.next(); - } - if(parts.value && parts.value.length>1) { - const result = parts.value[2]; - return [result.substr(0,result.lastIndexOf("}")+1),parts.value.index]; //this is a workaround in case sync merges two files together and one version is still an old version without the ```codeblock - } - return [data,parts.value.index]; -} - -export class ExcalidrawData { - private textElements:Map = null; - public scene:any = null; - private file:TFile = null; - private app:App; - private showLinkBrackets: boolean; - private linkPrefix: string; - private urlPrefix: string; - private textMode: TextMode = TextMode.raw; - private plugin: ExcalidrawPlugin; - public loaded: boolean = false; - public files:Map = null; //fileId, path - - constructor(plugin: ExcalidrawPlugin) { - this.plugin = plugin; - this.app = plugin.app; - } - - /** - * Loads a new drawing - * @param {TFile} file - the MD file containing the Excalidraw drawing - * @returns {boolean} - true if file was loaded, false if there was an error - */ - public async loadData(data: string,file: TFile, textMode:TextMode):Promise { - this.loaded = false; - this.file = file; - this.textElements = new Map(); - this.files = new Map(); - - //I am storing these because if the settings change while a drawing is open parsing will run into errors during save - //The drawing will use these values until next drawing is loaded or this drawing is re-loaded - this.setShowLinkBrackets(); - this.setLinkPrefix(); - this.setUrlPrefix(); - - this.scene = null; - - //In compatibility mode if the .excalidraw file was more recently updated than the .md file, then the .excalidraw file - //should be loaded as the scene. - //This feature is mostly likely only relevant to people who use Obsidian and Logseq on the same vault and edit .excalidraw - //drawings in Logseq. - if (this.plugin.settings.syncExcalidraw) { - const excalfile = file.path.substring(0,file.path.lastIndexOf('.md')) + '.excalidraw'; - const f = this.app.vault.getAbstractFileByPath(excalfile); - if(f && f instanceof TFile && f.stat.mtime>file.stat.mtime) { //the .excalidraw file is newer then the .md file - const d = await this.app.vault.read(f); - this.scene = JSON.parse(d); - } - } - - //Load scene: Read the JSON string after "# Drawing" - const [scene,pos] = getJSON(data); - if (pos === -1) { - return false; //JSON not found - } - if (!this.scene) { - this.scene = JSON_parse(scene); //this is a workaround to address when files are mereged by sync and one version is still an old markdown without the codeblock ``` - } - data = data.substring(0,pos); - - //The Markdown # Text Elements take priority over the JSON text elements. Assuming the scenario in which the link was updated due to filename changes - //The .excalidraw JSON is modified to reflect the MD in case of difference - //Read the text elements into the textElements Map - let position = data.search(/(^%%\n)?# Text Elements\n/m); - if(position==-1) { - await this.setTextMode(textMode,false); - this.loaded = true; - return true; //Text Elements header does not exist - } - position += data.match(/((^%%\n)?# Text Elements\n)/m)[0].length - - data = data.substring(position); - position = 0; - - //iterating through all the text elements in .md - //Text elements always contain the raw value - const BLOCKREF_LEN:number = " ^12345678\n\n".length; - const res = data.matchAll(/\s\^(.{8})[\n]+/g); - let parts; - while(!(parts = res.next()).done) { - const text = data.substring(position,parts.value.index); - const id:string = parts.value[1]; - this.textElements.set(id,{raw: text, parsed: await this.parse(text)}); - //this will set the rawText field of text elements imported from files before 1.3.14, and from other instances of Excalidraw - const textEl = this.scene.elements.filter((el:any)=>el.id===id)[0]; - if(textEl && (!textEl.rawText || textEl.rawText === "")) textEl.rawText = text; - - position = parts.value.index + BLOCKREF_LEN; - } - - //Check to see if there are text elements in the JSON that were missed from the # Text Elements section - //e.g. if the entire text elements section was deleted. - this.findNewTextElementsInScene(); - await this.setTextMode(textMode,true); - this.loaded = true; - return true; - } - - public async loadLegacyData(data: string,file: TFile):Promise { - this.file = file; - this.textElements = new Map(); - this.setShowLinkBrackets(); - this.setLinkPrefix(); - this.setUrlPrefix(); - this.scene = JSON.parse(data); - this.findNewTextElementsInScene(); - await this.setTextMode(TextMode.raw,true); //legacy files are always displayed in raw mode. - return true; - } - - public async setTextMode(textMode:TextMode,forceupdate:boolean=false) { - this.textMode = textMode; - await this.updateSceneTextElements(forceupdate); - } - - //update a single text element in the scene if the newText is different - public updateTextElement(sceneTextElement:any, newText:string, forceUpdate:boolean = false) { - if(forceUpdate || newText!=sceneTextElement.text) { - const measure = measureText(newText,sceneTextElement.fontSize,sceneTextElement.fontFamily); - sceneTextElement.text = newText; - sceneTextElement.width = measure.w; - sceneTextElement.height = measure.h; - sceneTextElement.baseline = measure.baseline; - } - } - - /** - * Updates the TextElements in the Excalidraw scene based on textElements MAP in ExcalidrawData - * Depending on textMode, TextElements will receive their raw or parsed values - * @param forceupdate : will update text elements even if text contents has not changed, this will - * correct sizing issues - */ - private async updateSceneTextElements(forceupdate:boolean=false) { - //update text in scene based on textElements Map - //first get scene text elements - const texts = this.scene.elements?.filter((el:any)=> el.type=="text") - for (const te of texts) { - this.updateTextElement(te,await this.getText(te.id),forceupdate); - } - } - - private async getText(id:string):Promise { - if (this.textMode == TextMode.parsed) { - if(!this.textElements.get(id).parsed) { - const raw = this.textElements.get(id).raw; - this.textElements.set(id,{raw:raw, parsed: await this.parse(raw)}) - } - //console.log("parsed",this.textElements.get(id).parsed); - return this.textElements.get(id).parsed; - } - //console.log("raw",this.textElements.get(id).raw); - return this.textElements.get(id).raw; - } - - /** - * check for textElements in Scene missing from textElements Map - * @returns {boolean} - true if there were changes - */ - private findNewTextElementsInScene():boolean { - //console.log("Excalidraw.Data.findNewTextElementsInScene()"); - //get scene text elements - const texts = this.scene.elements?.filter((el:any)=> el.type=="text") - - let jsonString = JSON.stringify(this.scene); - - let dirty:boolean = false; //to keep track if the json has changed - let id:string; //will be used to hold the new 8 char long ID for textelements that don't yet appear under # Text Elements - for (const te of texts) { - id = te.id; - //replacing Excalidraw text IDs with my own nanoid, because default IDs may contain - //characters not recognized by Obsidian block references - //also Excalidraw IDs are inconveniently long - if(te.id.length>8) { - dirty = true; - id=nanoid(); - jsonString = jsonString.replaceAll(te.id,id); //brute force approach to replace all occurances (e.g. links, groups,etc.) - } - if(te.id.length > 8 && this.textElements.has(te.id)) { //element was created with onBeforeTextSubmit - const element = this.textElements.get(te.id); - this.textElements.set(id,{raw: element.raw, parsed: element.parsed}) - this.textElements.delete(te.id); //delete the old ID from the Map - dirty = true; - } else if(!this.textElements.has(id)) { - dirty = true; - const raw = (te.rawText && te.rawText!==""?te.rawText:te.text); //this is for compatibility with drawings created before the rawText change on ExcalidrawTextElement - this.textElements.set(id,{raw: raw, parsed: null}); - this.parseasync(id,raw); - } - } - if(dirty) { //reload scene json in case it has changed - this.scene = JSON.parse(jsonString); - } - - return dirty; - } - - /** - * update text element map by deleting entries that are no long in the scene - * and updating the textElement map based on the text updated in the scene - */ - private async updateTextElementsFromScene() { - for(const key of this.textElements.keys()){ - //find text element in the scene - const el = this.scene.elements?.filter((el:any)=> el.type=="text" && el.id==key); - if(el.length==0) { - this.textElements.delete(key); //if no longer in the scene, delete the text element - } else { - const text = await this.getText(key); - if(text != el[0].text) { - this.textElements.set(key,{raw: el[0].text,parsed: await this.parse(el[0].text)}); - } - } - } - } - - private async parseasync(key:string, raw:string) { - this.textElements.set(key,{raw:raw,parsed: await this.parse(raw)}); - } - - private parseLinks(text:string, position:number, parts:any):string { - return text.substring(position,parts.value.index) + - (this.showLinkBrackets ? "[[" : "") + - REGEX_LINK.getAliasOrLink(parts) + - (this.showLinkBrackets ? "]]" : ""); - } - - /** - * - * @param text - * @returns [string,number] - the transcluded text, and the line number for the location of the text - */ - public async getTransclusion (text:string):Promise<[string,number]> { - //file-name#^blockref - //1 2 3 - const REG_FILE_BLOCKREF = /(.*)#(\^)?(.*)/g; - const parts=text.matchAll(REG_FILE_BLOCKREF).next(); - if(!parts.done && !parts.value[1]) return [text,0]; //filename not found - const filename = parts.done ? text : parts.value[1]; - const file = this.app.metadataCache.getFirstLinkpathDest(filename,this.file.path); - if(!file || !(file instanceof TFile)) return [text,0]; - const contents = await this.app.vault.cachedRead(file); - if(parts.done) { //no blockreference - return([contents.substr(0,this.plugin.settings.pageTransclusionCharLimit),0]); - } - const isParagraphRef = parts.value[2] ? true : false; //does the reference contain a ^ character? - const id = parts.value[3]; //the block ID or heading text - const blocks = (await this.app.metadataCache.blockCache.getForFile({isCancelled: ()=>false},file)).blocks.filter((block:any)=>block.node.type!="comment"); - if(!blocks) return [text,0]; - if(isParagraphRef) { - let para = blocks.filter((block:any)=>block.node.id == id)[0]?.node; - if(!para) return [text,0]; - if(["blockquote","listItem"].includes(para.type)) para = para.children[0]; //blockquotes are special, they have one child, which has the paragraph - const startPos = para.position.start.offset; - const lineNum = para.position.start.line; - const endPos = para.children[para.children.length-1]?.position.start.offset-1; //alternative: filter((c:any)=>c.type=="blockid")[0] - return [contents.substr(startPos,endPos-startPos),lineNum] - - } else { - const headings = blocks.filter((block:any)=>block.display.startsWith("#")); - let startPos:number = null; - let lineNum:number = 0; - let endPos:number = null; - for(let i=0;i{ - let outString = ""; - let position = 0; - const res = REGEX_LINK.getRes(text); - let linkIcon = false; - let urlIcon = false; - let parts; - while(!(parts=res.next()).done) { - if (REGEX_LINK.isTransclusion(parts)) { //transclusion //parts.value[1] || parts.value[4] - const [contents,lineNum] = await this.getTransclusion(REGEX_LINK.getLink(parts)); - outString += text.substring(position,parts.value.index) + - wrapText(contents,REGEX_LINK.getWrapLength(parts),this.plugin.settings.forceWrap); - } else { - const parsedLink = this.parseLinks(text,position,parts); - if(parsedLink) { - outString += parsedLink; - if(!(urlIcon || linkIcon)) - if(REGEX_LINK.getLink(parts).match(REG_LINKINDEX_HYPERLINK)) urlIcon = true; - else linkIcon = true; - } - } - position = parts.value.index + parts.value[0].length; - } - outString += text.substring(position,text.length); - if (linkIcon) { - outString = this.linkPrefix + outString; - } - if (urlIcon) { - outString = this.urlPrefix + outString; - } - - return outString; - } - - /** - * Does a quick parse of the raw text. Returns the parsed string if raw text does not include a transclusion. - * Return null if raw text includes a transclusion. - * This is implemented in a separate function, because by nature resolving a transclusion is an asynchronious - * activity. Quick parse gets the job done synchronously if possible. - * @param text - */ - private quickParse(text:string):string { - const hasTransclusion = (text:string):boolean => { - const res = REGEX_LINK.getRes(text); - let parts; - while(!(parts=res.next()).done) { - if (REGEX_LINK.isTransclusion(parts)) return true; - } - return false; - } - if (hasTransclusion(text)) return null; - - let outString = ""; - let position = 0; - const res = REGEX_LINK.getRes(text); - let linkIcon = false; - let urlIcon = false; - let parts; - while(!(parts=res.next()).done) { - const parsedLink = this.parseLinks(text,position,parts); - if(parsedLink) { - outString += parsedLink; - if(!(urlIcon || linkIcon)) - if(REGEX_LINK.getLink(parts).match(REG_LINKINDEX_HYPERLINK)) urlIcon = true; - else linkIcon = true; - } - position = parts.value.index + parts.value[0].length; - } - outString += text.substring(position,text.length); - if (linkIcon) { - outString = this.linkPrefix + outString; - } - if (urlIcon) { - outString = this.urlPrefix + outString; - } - return outString; - } - - - /** - * Generate markdown file representation of excalidraw drawing - * @returns markdown string - */ - generateMD():string { - let outString = '# Text Elements\n'; - for(const key of this.textElements.keys()){ - outString += this.textElements.get(key).raw+' ^'+key+'\n\n'; - } - if(this.files.size>0) { - outString += '\n# Embedded files\n'; - for(const key of this.files.keys()) { - outString += key +': [['+this.files.get(key) + ']]\n'; - } - outString += '\n'; - } - return outString + this.plugin.getMarkdownDrawingSection(JSON.stringify(this.scene)); - } - - public async syncElements(newScene:any):Promise { - //console.log("Excalidraw.Data.syncElements()"); - this.scene = newScene;//JSON_parse(newScene); - const result = this.setLinkPrefix() || this.setUrlPrefix() || this.setShowLinkBrackets(); - await this.updateTextElementsFromScene(); - return result || this.findNewTextElementsInScene(); - } - - public async updateScene(newScene:any){ - //console.log("Excalidraw.Data.updateScene()"); - this.scene = JSON_parse(newScene); - const result = this.setLinkPrefix() || this.setUrlPrefix() || this.setShowLinkBrackets(); - await this.updateTextElementsFromScene(); - if(result || this.findNewTextElementsInScene()) { - await this.updateSceneTextElements(); - return true; - }; - return false; - } - - public getRawText(id:string) { - return this.textElements.get(id)?.raw; - } - - public getParsedText(id:string):string { - return this.textElements.get(id)?.parsed; - } - - public setTextElement(elementID:string, rawText:string, updateScene:Function):string { - const parseResult = this.quickParse(rawText); //will return the parsed result if raw text does not include transclusion - if(parseResult) { //No transclusion - this.textElements.set(elementID,{raw: rawText,parsed: parseResult}); - return parseResult; - } - //transclusion needs to be resolved asynchornously - this.parse(rawText).then((parsedText:string)=> { - this.textElements.set(elementID,{raw: rawText,parsed: parsedText}); - if(parsedText) updateScene(parsedText); - }); - return null; - } - - public async addTextElement(elementID:string, rawText:string):Promise { - const parseResult = await this.parse(rawText); - this.textElements.set(elementID,{raw: rawText,parsed: parseResult}); - return parseResult; - } - - public deleteTextElement(id:string) { - this.textElements.delete(id); - } - - private setLinkPrefix():boolean { - const linkPrefix = this.linkPrefix; - const fileCache = this.app.metadataCache.getFileCache(this.file); - if (fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_PREFIX]!=null) { - this.linkPrefix=fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_PREFIX]; - } else { - this.linkPrefix = this.plugin.settings.linkPrefix; - } - return linkPrefix != this.linkPrefix; - } - - private setUrlPrefix():boolean { - const urlPrefix = this.urlPrefix; - const fileCache = this.app.metadataCache.getFileCache(this.file); - if (fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_URL_PREFIX]!=null) { - this.urlPrefix=fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_URL_PREFIX]; - } else { - this.urlPrefix = this.plugin.settings.urlPrefix; - } - return urlPrefix != this.urlPrefix; - } - - private setShowLinkBrackets():boolean { - const showLinkBrackets = this.showLinkBrackets; - const fileCache = this.app.metadataCache.getFileCache(this.file); - if (fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_LINK_BRACKETS]!=null) { - this.showLinkBrackets=fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_LINK_BRACKETS]!=false; - } else { - this.showLinkBrackets = this.plugin.settings.showLinkBrackets; - } - return showLinkBrackets != this.showLinkBrackets; - } - -} - - +import { App, normalizePath, TFile } from "obsidian"; +import { + nanoid, + FRONTMATTER_KEY_CUSTOM_PREFIX, + FRONTMATTER_KEY_CUSTOM_LINK_BRACKETS, + FRONTMATTER_KEY_CUSTOM_URL_PREFIX, +} from "./constants"; +import { measureText } from "./ExcalidrawAutomate"; +import ExcalidrawPlugin from "./main"; +import { + JSON_parse +} from "./constants"; +import { TextMode } from "./ExcalidrawView"; +import { wrapText } from "./Utils"; +import { FileId } from "@zsviczian/excalidraw/types/element/types"; + + +declare module "obsidian" { + interface MetadataCache { + blockCache: { + getForFile(x:any,f:TAbstractFile):any; + } + } +} + +export const REGEX_LINK = { + //![[link|alias]] [alias](link){num} + // 1 2 3 4 5 6 7 8 9 + EXPR: /(!)?(\[\[([^|\]]+)\|?([^\]]+)?]]|\[([^\]]*)]\(([^)]*)\))(\{(\d+)\})?/g, //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/187 + getRes: (text:string):IterableIterator => { + return text.matchAll(REGEX_LINK.EXPR); + }, + isTransclusion: (parts: IteratorResult):boolean => { + return parts.value[1] ? true:false; + }, + getLink: (parts: IteratorResult):string => { + return parts.value[3] ? parts.value[3] : parts.value[6]; + }, + isWikiLink: (parts: IteratorResult):boolean => { + return parts.value[3] ? true:false; + }, + getAliasOrLink: (parts: IteratorResult):string => { + return REGEX_LINK.isWikiLink(parts) + ? (parts.value[4] ? parts.value[4] : parts.value[3]) + : (parts.value[5] ? parts.value[5] : parts.value[6]); + }, + getWrapLength: (parts: IteratorResult):number => { + return parts.value[8]; + } +} + + +export const REG_LINKINDEX_HYPERLINK = /^\w+:\/\//; + +const DRAWING_REG = /\n%%\n# Drawing\n[^`]*(```json\n)([\s\S]*?)```/gm; //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/182 +const DRAWING_REG_FALLBACK = /\n# Drawing\n(```json\n)?(.*)(```)?(%%)?/gm; +export function getJSON(data:string):[string,number] { + let res = data.matchAll(DRAWING_REG); + + //In case the user adds a text element with the contents "# Drawing\n" + let parts; + parts = res.next(); + if(parts.done) { //did not find a match + res = data.matchAll(DRAWING_REG_FALLBACK); + parts = res.next(); + } + if(parts.value && parts.value.length>1) { + const result = parts.value[2]; + return [result.substr(0,result.lastIndexOf("}")+1),parts.value.index]; //this is a workaround in case sync merges two files together and one version is still an old version without the ```codeblock + } + return [data,parts.value.index]; +} + +export class ExcalidrawData { + private textElements:Map = null; + public scene:any = null; + private file:TFile = null; + private app:App; + private showLinkBrackets: boolean; + private linkPrefix: string; + private urlPrefix: string; + private textMode: TextMode = TextMode.raw; + private plugin: ExcalidrawPlugin; + public loaded: boolean = false; + public files:Map = null; //fileId, path + + constructor(plugin: ExcalidrawPlugin) { + this.plugin = plugin; + this.app = plugin.app; + } + + /** + * Loads a new drawing + * @param {TFile} file - the MD file containing the Excalidraw drawing + * @returns {boolean} - true if file was loaded, false if there was an error + */ + public async loadData(data: string,file: TFile, textMode:TextMode):Promise { + this.loaded = false; + this.file = file; + this.textElements = new Map(); + this.files = new Map(); + + //I am storing these because if the settings change while a drawing is open parsing will run into errors during save + //The drawing will use these values until next drawing is loaded or this drawing is re-loaded + this.setShowLinkBrackets(); + this.setLinkPrefix(); + this.setUrlPrefix(); + + this.scene = null; + + //In compatibility mode if the .excalidraw file was more recently updated than the .md file, then the .excalidraw file + //should be loaded as the scene. + //This feature is mostly likely only relevant to people who use Obsidian and Logseq on the same vault and edit .excalidraw + //drawings in Logseq. + if (this.plugin.settings.syncExcalidraw) { + const excalfile = file.path.substring(0,file.path.lastIndexOf('.md')) + '.excalidraw'; + const f = this.app.vault.getAbstractFileByPath(excalfile); + if(f && f instanceof TFile && f.stat.mtime>file.stat.mtime) { //the .excalidraw file is newer then the .md file + const d = await this.app.vault.read(f); + this.scene = JSON.parse(d); + } + } + + //Load scene: Read the JSON string after "# Drawing" + const [scene,pos] = getJSON(data); + if (pos === -1) { + return false; //JSON not found + } + if (!this.scene) { + this.scene = JSON_parse(scene); //this is a workaround to address when files are mereged by sync and one version is still an old markdown without the codeblock ``` + } + data = data.substring(0,pos); + + //The Markdown # Text Elements take priority over the JSON text elements. Assuming the scenario in which the link was updated due to filename changes + //The .excalidraw JSON is modified to reflect the MD in case of difference + //Read the text elements into the textElements Map + let position = data.search(/(^%%\n)?# Text Elements\n/m); + if(position==-1) { + await this.setTextMode(textMode,false); + this.loaded = true; + return true; //Text Elements header does not exist + } + position += data.match(/((^%%\n)?# Text Elements\n)/m)[0].length + + data = data.substring(position); + position = 0; + + //iterating through all the text elements in .md + //Text elements always contain the raw value + const BLOCKREF_LEN:number = " ^12345678\n\n".length; + const res = data.matchAll(/\s\^(.{8})[\n]+/g); + let parts; + while(!(parts = res.next()).done) { + const text = data.substring(position,parts.value.index); + const id:string = parts.value[1]; + this.textElements.set(id,{raw: text, parsed: await this.parse(text)}); + //this will set the rawText field of text elements imported from files before 1.3.14, and from other instances of Excalidraw + const textEl = this.scene.elements.filter((el:any)=>el.id===id)[0]; + if(textEl && (!textEl.rawText || textEl.rawText === "")) textEl.rawText = text; + + position = parts.value.index + BLOCKREF_LEN; + } + + //Check to see if there are text elements in the JSON that were missed from the # Text Elements section + //e.g. if the entire text elements section was deleted. + this.findNewTextElementsInScene(); + await this.setTextMode(textMode,true); + this.loaded = true; + return true; + } + + public async loadLegacyData(data: string,file: TFile):Promise { + this.file = file; + this.textElements = new Map(); + this.setShowLinkBrackets(); + this.setLinkPrefix(); + this.setUrlPrefix(); + this.scene = JSON.parse(data); + this.findNewTextElementsInScene(); + await this.setTextMode(TextMode.raw,true); //legacy files are always displayed in raw mode. + return true; + } + + public async setTextMode(textMode:TextMode,forceupdate:boolean=false) { + this.textMode = textMode; + await this.updateSceneTextElements(forceupdate); + } + + //update a single text element in the scene if the newText is different + public updateTextElement(sceneTextElement:any, newText:string, forceUpdate:boolean = false) { + if(forceUpdate || newText!=sceneTextElement.text) { + const measure = measureText(newText,sceneTextElement.fontSize,sceneTextElement.fontFamily); + sceneTextElement.text = newText; + sceneTextElement.width = measure.w; + sceneTextElement.height = measure.h; + sceneTextElement.baseline = measure.baseline; + } + } + + /** + * Updates the TextElements in the Excalidraw scene based on textElements MAP in ExcalidrawData + * Depending on textMode, TextElements will receive their raw or parsed values + * @param forceupdate : will update text elements even if text contents has not changed, this will + * correct sizing issues + */ + private async updateSceneTextElements(forceupdate:boolean=false) { + //update text in scene based on textElements Map + //first get scene text elements + const texts = this.scene.elements?.filter((el:any)=> el.type=="text") + for (const te of texts) { + this.updateTextElement(te,await this.getText(te.id),forceupdate); + } + } + + private async getText(id:string):Promise { + if (this.textMode == TextMode.parsed) { + if(!this.textElements.get(id).parsed) { + const raw = this.textElements.get(id).raw; + this.textElements.set(id,{raw:raw, parsed: await this.parse(raw)}) + } + //console.log("parsed",this.textElements.get(id).parsed); + return this.textElements.get(id).parsed; + } + //console.log("raw",this.textElements.get(id).raw); + return this.textElements.get(id).raw; + } + + /** + * check for textElements in Scene missing from textElements Map + * @returns {boolean} - true if there were changes + */ + private findNewTextElementsInScene():boolean { + //console.log("Excalidraw.Data.findNewTextElementsInScene()"); + //get scene text elements + const texts = this.scene.elements?.filter((el:any)=> el.type=="text") + + let jsonString = JSON.stringify(this.scene); + + let dirty:boolean = false; //to keep track if the json has changed + let id:string; //will be used to hold the new 8 char long ID for textelements that don't yet appear under # Text Elements + for (const te of texts) { + id = te.id; + //replacing Excalidraw text IDs with my own nanoid, because default IDs may contain + //characters not recognized by Obsidian block references + //also Excalidraw IDs are inconveniently long + if(te.id.length>8) { + dirty = true; + id=nanoid(); + jsonString = jsonString.replaceAll(te.id,id); //brute force approach to replace all occurances (e.g. links, groups,etc.) + } + if(te.id.length > 8 && this.textElements.has(te.id)) { //element was created with onBeforeTextSubmit + const element = this.textElements.get(te.id); + this.textElements.set(id,{raw: element.raw, parsed: element.parsed}) + this.textElements.delete(te.id); //delete the old ID from the Map + dirty = true; + } else if(!this.textElements.has(id)) { + dirty = true; + const raw = (te.rawText && te.rawText!==""?te.rawText:te.text); //this is for compatibility with drawings created before the rawText change on ExcalidrawTextElement + this.textElements.set(id,{raw: raw, parsed: null}); + this.parseasync(id,raw); + } + } + if(dirty) { //reload scene json in case it has changed + this.scene = JSON.parse(jsonString); + } + + return dirty; + } + + /** + * update text element map by deleting entries that are no long in the scene + * and updating the textElement map based on the text updated in the scene + */ + private async updateTextElementsFromScene() { + for(const key of this.textElements.keys()){ + //find text element in the scene + const el = this.scene.elements?.filter((el:any)=> el.type=="text" && el.id==key); + if(el.length==0) { + this.textElements.delete(key); //if no longer in the scene, delete the text element + } else { + const text = await this.getText(key); + if(text != el[0].text) { + this.textElements.set(key,{raw: el[0].text,parsed: await this.parse(el[0].text)}); + } + } + } + } + + private async parseasync(key:string, raw:string) { + this.textElements.set(key,{raw:raw,parsed: await this.parse(raw)}); + } + + private parseLinks(text:string, position:number, parts:any):string { + return text.substring(position,parts.value.index) + + (this.showLinkBrackets ? "[[" : "") + + REGEX_LINK.getAliasOrLink(parts) + + (this.showLinkBrackets ? "]]" : ""); + } + + /** + * + * @param text + * @returns [string,number] - the transcluded text, and the line number for the location of the text + */ + public async getTransclusion (text:string):Promise<[string,number]> { + //file-name#^blockref + //1 2 3 + const REG_FILE_BLOCKREF = /(.*)#(\^)?(.*)/g; + const parts=text.matchAll(REG_FILE_BLOCKREF).next(); + if(!parts.done && !parts.value[1]) return [text,0]; //filename not found + const filename = parts.done ? text : parts.value[1]; + const file = this.app.metadataCache.getFirstLinkpathDest(filename,this.file.path); + if(!file || !(file instanceof TFile)) return [text,0]; + const contents = await this.app.vault.cachedRead(file); + if(parts.done) { //no blockreference + return([contents.substr(0,this.plugin.settings.pageTransclusionCharLimit),0]); + } + const isParagraphRef = parts.value[2] ? true : false; //does the reference contain a ^ character? + const id = parts.value[3]; //the block ID or heading text + const blocks = (await this.app.metadataCache.blockCache.getForFile({isCancelled: ()=>false},file)).blocks.filter((block:any)=>block.node.type!="comment"); + if(!blocks) return [text,0]; + if(isParagraphRef) { + let para = blocks.filter((block:any)=>block.node.id == id)[0]?.node; + if(!para) return [text,0]; + if(["blockquote","listItem"].includes(para.type)) para = para.children[0]; //blockquotes are special, they have one child, which has the paragraph + const startPos = para.position.start.offset; + const lineNum = para.position.start.line; + const endPos = para.children[para.children.length-1]?.position.start.offset-1; //alternative: filter((c:any)=>c.type=="blockid")[0] + return [contents.substr(startPos,endPos-startPos),lineNum] + + } else { + const headings = blocks.filter((block:any)=>block.display.startsWith("#")); + let startPos:number = null; + let lineNum:number = 0; + let endPos:number = null; + for(let i=0;i{ + let outString = ""; + let position = 0; + const res = REGEX_LINK.getRes(text); + let linkIcon = false; + let urlIcon = false; + let parts; + while(!(parts=res.next()).done) { + if (REGEX_LINK.isTransclusion(parts)) { //transclusion //parts.value[1] || parts.value[4] + const [contents,lineNum] = await this.getTransclusion(REGEX_LINK.getLink(parts)); + outString += text.substring(position,parts.value.index) + + wrapText(contents,REGEX_LINK.getWrapLength(parts),this.plugin.settings.forceWrap); + } else { + const parsedLink = this.parseLinks(text,position,parts); + if(parsedLink) { + outString += parsedLink; + if(!(urlIcon || linkIcon)) + if(REGEX_LINK.getLink(parts).match(REG_LINKINDEX_HYPERLINK)) urlIcon = true; + else linkIcon = true; + } + } + position = parts.value.index + parts.value[0].length; + } + outString += text.substring(position,text.length); + if (linkIcon) { + outString = this.linkPrefix + outString; + } + if (urlIcon) { + outString = this.urlPrefix + outString; + } + + return outString; + } + + /** + * Does a quick parse of the raw text. Returns the parsed string if raw text does not include a transclusion. + * Return null if raw text includes a transclusion. + * This is implemented in a separate function, because by nature resolving a transclusion is an asynchronious + * activity. Quick parse gets the job done synchronously if possible. + * @param text + */ + private quickParse(text:string):string { + const hasTransclusion = (text:string):boolean => { + const res = REGEX_LINK.getRes(text); + let parts; + while(!(parts=res.next()).done) { + if (REGEX_LINK.isTransclusion(parts)) return true; + } + return false; + } + if (hasTransclusion(text)) return null; + + let outString = ""; + let position = 0; + const res = REGEX_LINK.getRes(text); + let linkIcon = false; + let urlIcon = false; + let parts; + while(!(parts=res.next()).done) { + const parsedLink = this.parseLinks(text,position,parts); + if(parsedLink) { + outString += parsedLink; + if(!(urlIcon || linkIcon)) + if(REGEX_LINK.getLink(parts).match(REG_LINKINDEX_HYPERLINK)) urlIcon = true; + else linkIcon = true; + } + position = parts.value.index + parts.value[0].length; + } + outString += text.substring(position,text.length); + if (linkIcon) { + outString = this.linkPrefix + outString; + } + if (urlIcon) { + outString = this.urlPrefix + outString; + } + return outString; + } + + + /** + * Generate markdown file representation of excalidraw drawing + * @returns markdown string + */ + generateMD():string { + let outString = '# Text Elements\n'; + for(const key of this.textElements.keys()){ + outString += this.textElements.get(key).raw+' ^'+key+'\n\n'; + } + if(this.files.size>0) { + outString += '\n# Embedded files\n'; + for(const key of this.files.keys()) { + outString += key +': [['+this.files.get(key) + ']]\n'; + } + outString += '\n'; + } + return outString + this.plugin.getMarkdownDrawingSection(JSON.stringify(this.scene,null,"\t")); + } + + public async syncElements(newScene:any):Promise { + //console.log("Excalidraw.Data.syncElements()"); + this.scene = newScene;//JSON_parse(newScene); + const result = this.setLinkPrefix() || this.setUrlPrefix() || this.setShowLinkBrackets(); + await this.updateTextElementsFromScene(); + return result || this.findNewTextElementsInScene(); + } + + public async updateScene(newScene:any){ + //console.log("Excalidraw.Data.updateScene()"); + this.scene = JSON_parse(newScene); + const result = this.setLinkPrefix() || this.setUrlPrefix() || this.setShowLinkBrackets(); + await this.updateTextElementsFromScene(); + if(result || this.findNewTextElementsInScene()) { + await this.updateSceneTextElements(); + return true; + }; + return false; + } + + public getRawText(id:string) { + return this.textElements.get(id)?.raw; + } + + public getParsedText(id:string):string { + return this.textElements.get(id)?.parsed; + } + + public setTextElement(elementID:string, rawText:string, updateScene:Function):string { + const parseResult = this.quickParse(rawText); //will return the parsed result if raw text does not include transclusion + if(parseResult) { //No transclusion + this.textElements.set(elementID,{raw: rawText,parsed: parseResult}); + return parseResult; + } + //transclusion needs to be resolved asynchornously + this.parse(rawText).then((parsedText:string)=> { + this.textElements.set(elementID,{raw: rawText,parsed: parsedText}); + if(parsedText) updateScene(parsedText); + }); + return null; + } + + public async addTextElement(elementID:string, rawText:string):Promise { + const parseResult = await this.parse(rawText); + this.textElements.set(elementID,{raw: rawText,parsed: parseResult}); + return parseResult; + } + + public deleteTextElement(id:string) { + this.textElements.delete(id); + } + + private setLinkPrefix():boolean { + const linkPrefix = this.linkPrefix; + const fileCache = this.app.metadataCache.getFileCache(this.file); + if (fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_PREFIX]!=null) { + this.linkPrefix=fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_PREFIX]; + } else { + this.linkPrefix = this.plugin.settings.linkPrefix; + } + return linkPrefix != this.linkPrefix; + } + + private setUrlPrefix():boolean { + const urlPrefix = this.urlPrefix; + const fileCache = this.app.metadataCache.getFileCache(this.file); + if (fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_URL_PREFIX]!=null) { + this.urlPrefix=fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_URL_PREFIX]; + } else { + this.urlPrefix = this.plugin.settings.urlPrefix; + } + return urlPrefix != this.urlPrefix; + } + + private setShowLinkBrackets():boolean { + const showLinkBrackets = this.showLinkBrackets; + const fileCache = this.app.metadataCache.getFileCache(this.file); + if (fileCache?.frontmatter && fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_LINK_BRACKETS]!=null) { + this.showLinkBrackets=fileCache.frontmatter[FRONTMATTER_KEY_CUSTOM_LINK_BRACKETS]!=false; + } else { + this.showLinkBrackets = this.plugin.settings.showLinkBrackets; + } + return showLinkBrackets != this.showLinkBrackets; + } + +} + + diff --git a/src/ExcalidrawView.ts b/src/ExcalidrawView.ts index 56f8f00..c03a5a8 100644 --- a/src/ExcalidrawView.ts +++ b/src/ExcalidrawView.ts @@ -98,8 +98,8 @@ export default class ExcalidrawView extends TextFileView { } const filepath = this.file.path.substring(0,this.file.path.lastIndexOf('.md')) + '.excalidraw'; const file = this.app.vault.getAbstractFileByPath(normalizePath(filepath)); - if(file && file instanceof TFile) this.app.vault.modify(file,JSON.stringify(scene)); - else this.app.vault.create(filepath,JSON.stringify(scene)); + if(file && file instanceof TFile) this.app.vault.modify(file,JSON.stringify(scene,null,"\t")); + else this.app.vault.create(filepath,JSON.stringify(scene,null,"\t")); } public saveSVG(scene?: any) { @@ -199,7 +199,7 @@ export default class ExcalidrawView extends TextFileView { if(this.plugin.settings.autoexportSVG) this.saveSVG(scene); if(this.plugin.settings.autoexportPNG) this.savePNG(scene); } - return JSON.stringify(scene); + return JSON.stringify(scene,null,"\t"); } return this.data; } @@ -496,12 +496,12 @@ export default class ExcalidrawView extends TextFileView { const folderpath = splitFolderAndFilename(this.file.path).folderpath; await checkAndCreateFolder(this.app.vault,folderpath); //create folder if it does not exist const fname = getNewUniqueFilepath(this.app.vault,filename,folderpath); - this.app.vault.create(fname,JSON.stringify(this.getScene())); + this.app.vault.create(fname,JSON.stringify(this.getScene(),null,"\t")); new Notice("Exported to " + fname,6000); }); return; } - download('data:text/plain;charset=utf-8',encodeURIComponent(JSON.stringify(this.getScene())), this.file.basename+'.excalidraw'); + download('data:text/plain;charset=utf-8',encodeURIComponent(JSON.stringify(this.getScene(),null,"\t")), this.file.basename+'.excalidraw'); }); }); } else { diff --git a/src/main.ts b/src/main.ts index 2e831b0..c01ecaf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -113,6 +113,7 @@ export default class ExcalidrawPlugin extends Plugin { //inspiration taken from kanban: //https://github.com/mgmeyers/obsidian-kanban/blob/44118e25661bff9ebfe54f71ae33805dc88ffa53/src/main.ts#L267 this.registerMonkeyPatches(); + new Notice("Excalidraw was updated. Files opened with this version will not open with the older version. Please update plugin on all your devices.\n\nI will remove this message with next update.",8000); if(this.settings.loadCount<1) this.migrationNotice(); const electron:string = process.versions.electron; if(electron.startsWith("8.")) { @@ -1137,7 +1138,7 @@ export default class ExcalidrawPlugin extends Plugin { } outString += te.text+' ^'+id+'\n\n'; } - return outString + this.getMarkdownDrawingSection(JSON.stringify(JSON_parse(data))); + return outString + this.getMarkdownDrawingSection(JSON.stringify(JSON_parse(data),null,"\t")); } public async createDrawing(filename: string, onNewPane: boolean, foldername?: string, initData?:string):Promise { diff --git a/versions.json b/versions.json index 58d72b8..695ac87 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "1.3.19": "0.11.13" + "1.3.20": "0.11.13" }