Files
obsidian-excalidraw-plugin/src/ExcalidrawView.ts
2021-10-19 19:45:32 +02:00

1092 lines
44 KiB
TypeScript

import {
TextFileView,
WorkspaceLeaf,
normalizePath,
TFile,
WorkspaceItem,
Notice,
Menu,
} from "obsidian";
import * as React from "react";
import * as ReactDOM from "react-dom";
import Excalidraw, {exportToSvg, getSceneVersion} from "@zsviczian/excalidraw";
import { ExcalidrawElement,ExcalidrawTextElement } from "@zsviczian/excalidraw/types/element/types";
import {
AppState,
LibraryItems
} from "@zsviczian/excalidraw/types/types";
import {
VIEW_TYPE_EXCALIDRAW,
ICON_NAME,
EXCALIDRAW_LIB_HEADER,
VIRGIL_FONT,
CASCADIA_FONT,
DISK_ICON_NAME,
PNG_ICON_NAME,
SVG_ICON_NAME,
FRONTMATTER_KEY,
TEXT_DISPLAY_RAW_ICON_NAME,
TEXT_DISPLAY_PARSED_ICON_NAME,
FULLSCREEN_ICON_NAME,
JSON_parse
} from './constants';
import ExcalidrawPlugin from './main';
import {estimateBounds, ExcalidrawAutomate, repositionElementsToCursor} from './ExcalidrawAutomate';
import { t } from "./lang/helpers";
import { ExcalidrawData, REG_LINKINDEX_HYPERLINK, REGEX_LINK } from "./ExcalidrawData";
import { checkAndCreateFolder, download, getNewOrAdjacentLeaf, getNewUniqueFilepath, rotatedDimensions, splitFolderAndFilename, viewportCoordsToSceneCoords } from "./Utils";
import { Prompt } from "./Prompt";
import { ClipboardData } from "@zsviczian/excalidraw/types/clipboard";
declare let window: ExcalidrawAutomate;
export enum TextMode {
parsed,
raw
}
interface WorkspaceItemExt extends WorkspaceItem {
containerEl: HTMLElement;
}
export interface ExportSettings {
withBackground: boolean,
withTheme: boolean
}
const REG_LINKINDEX_INVALIDCHARS = /[<>:"\\|?*]/g;
export default class ExcalidrawView extends TextFileView {
private excalidrawData: ExcalidrawData;
private getScene: Function = null;
public addElements: Function = null; //add elements to the active Excalidraw drawing
private getSelectedTextElement: Function = null;
public addText:Function = null;
private refresh: Function = null;
public excalidrawRef: React.MutableRefObject<any> = null;
private excalidrawWrapperRef: React.MutableRefObject<any> = null;
private justLoaded: boolean = false;
private plugin: ExcalidrawPlugin;
private dirty: string = null;
public autosaveTimer: any = null;
public autosaving:boolean = false;
public textMode:TextMode = TextMode.raw;
private textIsParsed_Element:HTMLElement;
private textIsRaw_Element:HTMLElement;
private preventReload:boolean = true;
public compatibilityMode: boolean = false;
//store key state for view mode link resolution
private ctrlKeyDown = false;
private shiftKeyDown = false;
private altKeyDown = false;
id: string = (this.leaf as any).id;
constructor(leaf: WorkspaceLeaf, plugin: ExcalidrawPlugin) {
super(leaf);
this.plugin = plugin;
this.excalidrawData = new ExcalidrawData(plugin);
}
public saveExcalidraw(scene?: any){
if(!scene) {
if (!this.getScene) return false;
scene = this.getScene();
}
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,null,"\t"));
else this.app.vault.create(filepath,JSON.stringify(scene,null,"\t"));
}
public saveSVG(scene?: any) {
if(!scene) {
if (!this.getScene) return false;
scene = this.getScene();
}
const filepath = this.file.path.substring(0,this.file.path.lastIndexOf(this.compatibilityMode ? '.excalidraw':'.md')) + '.svg';
const file = this.app.vault.getAbstractFileByPath(normalizePath(filepath));
(async () => {
const exportSettings: ExportSettings = {
withBackground: this.plugin.settings.exportWithBackground,
withTheme: this.plugin.settings.exportWithTheme
}
const svg = await ExcalidrawView.getSVG(scene,exportSettings);
if(!svg) return;
let serializer =new XMLSerializer();
const svgString = serializer.serializeToString(ExcalidrawView.embedFontsInSVG(svg));
if(file && file instanceof TFile) await this.app.vault.modify(file,svgString);
else await this.app.vault.create(filepath,svgString);
})();
}
public static embedFontsInSVG(svg:SVGSVGElement):SVGSVGElement {
//replace font references with base64 fonts
const includesVirgil = svg.querySelector("text[font-family^='Virgil']") != null;
const includesCascadia = svg.querySelector("text[font-family^='Cascadia']") != null;
const defs = svg.querySelector("defs");
if (defs && (includesCascadia || includesVirgil)) {
defs.innerHTML = "<style>" + (includesVirgil ? VIRGIL_FONT : "") + (includesCascadia ? CASCADIA_FONT : "")+"</style>";
}
return svg;
}
public savePNG(scene?: any) {
if(!scene) {
if (!this.getScene) return false;
scene = this.getScene();
}
const filepath = this.file.path.substring(0,this.file.path.lastIndexOf(this.compatibilityMode ? '.excalidraw':'.md')) + '.png';
const file = this.app.vault.getAbstractFileByPath(normalizePath(filepath));
(async () => {
const exportSettings: ExportSettings = {
withBackground: this.plugin.settings.exportWithBackground,
withTheme: this.plugin.settings.exportWithTheme
}
const png = await ExcalidrawView.getPNG(scene,exportSettings,this.plugin.settings.pngExportScale);
if(!png) return;
if(file && file instanceof TFile) await this.app.vault.modifyBinary(file,await png.arrayBuffer());
else await this.app.vault.createBinary(filepath,await png.arrayBuffer());
})();
}
async save(preventReload:boolean=true) {
if(!this.getScene) return;
this.preventReload = preventReload;
this.dirty = null;
if(this.compatibilityMode) {
await this.excalidrawData.syncElements(this.getScene());
} else {
if(await this.excalidrawData.syncElements(this.getScene()) && !this.autosaving) {
await this.loadDrawing(false);
}
}
await super.save();
}
// get the new file content
// if drawing is in Text Element Edit Lock, then everything should be parsed and in sync
// if drawing is in Text Element Edit Unlock, then everything is raw and parse and so an async function is not required here
getViewData () {
//console.log("ExcalidrawView.getViewData()");
if(!this.getScene) return this.data;
if(!this.excalidrawData.loaded) return this.data;
if(!this.compatibilityMode) {
let trimLocation = this.data.search(/(^%%\n)?# Text Elements\n/m);
if(trimLocation == -1) trimLocation = this.data.search(/(%%\n)?# Drawing\n/);
if(trimLocation == -1) return this.data;
const scene = this.excalidrawData.scene;
if(!this.autosaving) {
if(this.plugin.settings.autoexportSVG) this.saveSVG(scene);
if(this.plugin.settings.autoexportPNG) this.savePNG(scene);
if(this.plugin.settings.autoexportExcalidraw) this.saveExcalidraw(scene);
}
const header = this.data.substring(0,trimLocation)
.replace(/excalidraw-plugin:\s.*\n/,FRONTMATTER_KEY+": " + ( (this.textMode == TextMode.raw) ? "raw\n" : "parsed\n"));
return header + this.excalidrawData.generateMD();
}
if(this.compatibilityMode) {
const scene = this.excalidrawData.scene;
if(!this.autosaving) {
if(this.plugin.settings.autoexportSVG) this.saveSVG(scene);
if(this.plugin.settings.autoexportPNG) this.savePNG(scene);
}
return JSON.stringify(scene,null,"\t");
}
return this.data;
}
async handleLinkClick(view: ExcalidrawView, ev:MouseEvent) {
let text:string = (this.textMode == TextMode.parsed)
? this.excalidrawData.getRawText(this.getSelectedTextElement().id)
: this.getSelectedTextElement().text;
if(!text) {
new Notice(t("LINK_BUTTON_CLICK_NO_TEXT"),20000);
return;
}
text = text.replaceAll("\n",""); //https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/187
if(text.match(REG_LINKINDEX_HYPERLINK)) {
window.open(text,"_blank");
return;
}
const parts = REGEX_LINK.getRes(text).next();
if(!parts.value) {
const tags = text.matchAll(/#([\p{Letter}\p{Emoji_Presentation}\p{Number}\/_-]+)/ug).next();
if(!tags.value || tags.value.length<2) {
new Notice(t("TEXT_ELEMENT_EMPTY"),4000);
return;
}
const search=this.app.workspace.getLeavesOfType("search");
if(search.length==0) return;
//@ts-ignore
search[0].view.setQuery("tag:"+tags.value[1]);
this.app.workspace.revealLeaf(search[0]);
if(document.fullscreenElement === this.contentEl) {
document.exitFullscreen();
this.zoomToFit();
}
return;
}
text = REGEX_LINK.getLink(parts);
if(text.match(REG_LINKINDEX_HYPERLINK)) {
window.open(text,"_blank");
return;
}
let lineNum = null;
if(text.search("#")>-1) {
let t;
[t,lineNum] = await this.excalidrawData.getTransclusion(text);
text = text.substring(0,text.search("#"));
}
if(text.match(REG_LINKINDEX_INVALIDCHARS)) {
new Notice(t("FILENAME_INVALID_CHARS"),4000);
return;
}
const file = view.app.metadataCache.getFirstLinkpathDest(text,view.file.path);
if (!ev.altKey && !file) {
new Notice(t("FILE_DOES_NOT_EXIST"), 4000);
return;
}
try {
const f = view.file;
if(ev.shiftKey && document.fullscreenElement === this.contentEl) {
document.exitFullscreen();
this.zoomToFit();
}
const leaf = ev.shiftKey ? getNewOrAdjacentLeaf(this.plugin,view.leaf) : view.leaf;
view.app.workspace.setActiveLeaf(leaf);
leaf.view.app.workspace.openLinkText(text,view.file.path);
} catch (e) {
new Notice(e,4000);
}
}
onResize() {
if(!this.plugin.settings.zoomToFitOnResize) return;
if(!this.excalidrawRef) return;
this.zoomToFit(false);
}
onload() {
//console.log("ExcalidrawView.onload()");
this.addAction(DISK_ICON_NAME,t("FORCE_SAVE"),async (ev)=> {
await this.save(false);
this.plugin.triggerEmbedUpdates();
});
this.textIsRaw_Element = this.addAction(TEXT_DISPLAY_RAW_ICON_NAME,t("RAW"), (ev) => this.changeTextMode(TextMode.parsed));
this.textIsParsed_Element = this.addAction(TEXT_DISPLAY_PARSED_ICON_NAME,t("PARSED"), (ev) => this.changeTextMode(TextMode.raw));
this.addAction("link",t("OPEN_LINK"), (ev)=>this.handleLinkClick(this,ev));
if(!this.app.isMobile) {
this.addAction(FULLSCREEN_ICON_NAME,"Press ESC to exit fullscreen mode",()=>{
this.contentEl.requestFullscreen();//{navigationUI: "hide"});
if(this.excalidrawWrapperRef) this.excalidrawWrapperRef.current.focus();
});
this.contentEl.onfullscreenchange = () => {
this.zoomToFit();
}
}
//this is to solve sliding panes bug
if (this.app.workspace.layoutReady) {
(this.app.workspace.rootSplit as WorkspaceItem as WorkspaceItemExt).containerEl.addEventListener('scroll',(e)=>{if(this.refresh) this.refresh();});
} else {
this.app.workspace.onLayoutReady(
async () => (this.app.workspace.rootSplit as WorkspaceItem as WorkspaceItemExt).containerEl.addEventListener('scroll',(e)=>{if(this.refresh) this.refresh();})
);
}
this.setupAutosaveTimer();
}
public async changeTextMode(textMode:TextMode,reload:boolean=true) {
this.textMode = textMode;
if(textMode == TextMode.parsed) {
this.textIsRaw_Element.hide();
this.textIsParsed_Element.show();
} else {
this.textIsRaw_Element.show();
this.textIsParsed_Element.hide();
}
if(reload) {
await this.save(false);
this.excalidrawRef.current.history.clear(); //to avoid undo replacing links with parsed text
}
}
public setupAutosaveTimer() {
const timer = async () => {
if(this.dirty && (this.dirty == this.file?.path)) {
this.dirty = null;
this.autosaving=true;
if(this.excalidrawRef) await this.save();
this.autosaving=false;
}
}
if(this.autosaveTimer) clearInterval(this.autosaveTimer); // clear previous timer if one exists
this.autosaveTimer = setInterval(timer,20000);
}
//save current drawing when user closes workspace leaf
async onunload() {
if(this.autosaveTimer) {
clearInterval(this.autosaveTimer);
this.autosaveTimer = null;
}
}
public async reload(fullreload:boolean = false, file?:TFile){
if(this.preventReload) {
this.preventReload = false;
return;
}
if(!this.excalidrawRef) return;
if(!this.file) return;
if(file) this.data = await this.app.vault.cachedRead(file);
if(fullreload) await this.excalidrawData.loadData(this.data, this.file,this.textMode);
else await this.excalidrawData.setTextMode(this.textMode);
await this.loadDrawing(false);
this.dirty = null;
}
// clear the view content
clear() {
if(!this.excalidrawRef) return;
this.excalidrawRef.current.resetScene();
this.excalidrawRef.current.history.clear();
}
async setViewData (data: string, clear: boolean = false) {
if(clear) this.clear();
data = this.data = data.replaceAll("\r\n","\n").replaceAll("\r","\n");
this.app.workspace.onLayoutReady(async ()=>{
this.dirty = null;
this.compatibilityMode = this.file.extension == "excalidraw";
await this.plugin.loadSettings();
this.plugin.opencount++;
if(this.compatibilityMode) {
this.textIsRaw_Element.hide();
this.textIsParsed_Element.hide();
await this.excalidrawData.loadLegacyData(data,this.file);
if (!this.plugin.settings.compatibilityMode) {
new Notice(t("COMPATIBILITY_MODE"),4000);
}
} else {
const parsed = data.search("excalidraw-plugin: parsed\n")>-1 || data.search("excalidraw-plugin: locked\n")>-1; //locked for backward compatibility
this.changeTextMode(parsed ? TextMode.parsed : TextMode.raw,false);
try {
if(!(await this.excalidrawData.loadData(data, this.file,this.textMode))) return;
} catch(e) {
new Notice( "Error loading drawing:\n"
+ e.message
+ ((e.message === "Cannot read property 'index' of undefined")
? "\n'# Drawing' section is likely missing"
: "")
+ "\nTry manually fixing the file or restoring an earlier version from sync history" ,8000);
this.setMarkdownView();
return;
}
}
await this.loadDrawing(true)
});
}
/**
*
* @param justloaded - a flag to trigger zoom to fit after the drawing has been loaded
*/
private async loadDrawing(justloaded:boolean) {
const excalidrawData = this.excalidrawData.scene;
this.justLoaded = justloaded;
if(this.excalidrawRef) {
const viewModeEnabled = this.excalidrawRef.current.getAppState().viewModeEnabled;
const zenModeEnabled = this.excalidrawRef.current.getAppState().zenModeEnabled;
this.excalidrawRef.current.updateScene({
elements: excalidrawData.elements,
appState: {
zenModeEnabled: zenModeEnabled,
viewModeEnabled: viewModeEnabled,
... excalidrawData.appState,
},
commitToHistory: true,
});
if((this.app.workspace.activeLeaf === this.leaf) && this.excalidrawWrapperRef) {
this.excalidrawWrapperRef.current.focus();
}
} else {
this.instantiateExcalidraw({
elements: excalidrawData.elements,
appState: excalidrawData.appState,
libraryItems: await this.getLibrary(),
});
}
}
//Compatibility mode with .excalidraw files
canAcceptExtension(extension: string) {
return extension == "excalidraw";
}
// gets the title of the document
getDisplayText() {
if(this.file) return this.file.basename;
else return t("NOFILE");
}
// the view type name
getViewType() {
return VIEW_TYPE_EXCALIDRAW;
}
// icon for the view
getIcon() {
return ICON_NAME;
}
setMarkdownView() {
this.plugin.excalidrawFileModes[this.id || this.file.path] = "markdown";
this.plugin.setMarkdownView(this.leaf);
}
onMoreOptionsMenu(menu: Menu) {
// Add a menu item to force the board to markdown view
if(!this.compatibilityMode) {
menu
.addItem((item) => {
item
.setTitle(t("OPEN_AS_MD"))
.setIcon("document")
.onClick(async () => {
this.setMarkdownView();
});
})
.addItem((item) => {
item
.setTitle(t("EXPORT_EXCALIDRAW"))
.setIcon(ICON_NAME)
.onClick( async (ev) => {
if(!this.getScene || !this.file) return;
//@ts-ignore
if(this.app.isMobile) {
const prompt = new Prompt(this.app, "Please provide filename",this.file.basename,'filename, leave blank to cancel action');
prompt.openAndGetValue( async (filename:string)=> {
if(!filename) return;
filename = filename + ".excalidraw";
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(),null,"\t"));
new Notice("Exported to " + fname,6000);
});
return;
}
download('data:text/plain;charset=utf-8',encodeURIComponent(JSON.stringify(this.getScene(),null,"\t")), this.file.basename+'.excalidraw');
});
});
} else {
menu
.addItem((item) => {
item
.setTitle(t("CONVERT_FILE"))
.onClick(async () => {
await this.save();
this.plugin.openDrawing(await this.plugin.convertSingleExcalidrawToMD(this.file),false);
});
});
}
menu
.addItem((item) => {
item
.setTitle(t("SAVE_AS_PNG"))
.setIcon(PNG_ICON_NAME)
.onClick( async (ev)=> {
if(!this.getScene || !this.file) return;
if(ev.ctrlKey || ev.metaKey) {
const exportSettings: ExportSettings = {
withBackground: this.plugin.settings.exportWithBackground,
withTheme: this.plugin.settings.exportWithTheme
}
const png = await ExcalidrawView.getPNG(this.getScene(),exportSettings,this.plugin.settings.pngExportScale);
if(!png) return;
let reader = new FileReader();
reader.readAsDataURL(png);
const self = this;
reader.onloadend = function() {
let base64data = reader.result;
download(null,base64data,self.file.basename+'.png');
}
return;
}
this.savePNG();
});
})
.addItem((item) => {
item
.setTitle(t("SAVE_AS_SVG"))
.setIcon(SVG_ICON_NAME)
.onClick(async (ev)=> {
if(!this.getScene || !this.file) return;
if(ev.ctrlKey || ev.metaKey) {
const exportSettings: ExportSettings = {
withBackground: this.plugin.settings.exportWithBackground,
withTheme: this.plugin.settings.exportWithTheme
}
let svg = await ExcalidrawView.getSVG(this.getScene(),exportSettings);
if(!svg) return null;
svg = ExcalidrawView.embedFontsInSVG(svg);
download("data:image/svg+xml;base64",btoa(unescape(encodeURIComponent(svg.outerHTML))),this.file.basename+'.svg');
return;
}
this.saveSVG()
});
})
.addSeparator();
super.onMoreOptionsMenu(menu);
}
async getLibrary() {
const data = JSON_parse(this.plugin.getStencilLibrary());
return data?.library ? data.library : [];
}
private instantiateExcalidraw(initdata: any) {
//console.log("ExcalidrawView.instantiateExcalidraw()");
this.dirty = null;
const reactElement = React.createElement(() => {
let previousSceneVersion = 0;
let currentPosition = {x:0, y:0};
const excalidrawRef = React.useRef(null);
const excalidrawWrapperRef = React.useRef(null);
const [dimensions, setDimensions] = React.useState({
width: undefined,
height: undefined
});
this.excalidrawRef = excalidrawRef;
this.excalidrawWrapperRef = excalidrawWrapperRef;
React.useEffect(() => {
setDimensions({
width: this.contentEl.clientWidth,
height: this.contentEl.clientHeight,
});
const onResize = () => {
try {
setDimensions({
width: this.contentEl.clientWidth,
height: this.contentEl.clientHeight,
});
} catch(err) {console.log ("Excalidraw React-Wrapper, onResize ",err)}
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [excalidrawWrapperRef]);
this.getSelectedTextElement = ():{id: string, text:string} => {
if(!excalidrawRef?.current) return {id:null,text:null};
if(this.excalidrawRef.current.getAppState().viewModeEnabled) {
if(selectedTextElement) {
const retval = selectedTextElement;
selectedTextElement == null;
return retval;
}
return {id:null,text:null};
}
const selectedElement = excalidrawRef.current.getSceneElements().filter((el:any)=>el.id==Object.keys(excalidrawRef.current.getAppState().selectedElementIds)[0]);
if(selectedElement.length==0) return {id:null,text:null};
if(selectedElement[0].type == "text") return {id:selectedElement[0].id, text:selectedElement[0].text}; //a text element was selected. Return text
if(selectedElement[0].groupIds.length == 0) return {id:null,text:null}; //is the selected element part of a group?
const group = selectedElement[0].groupIds[0]; //if yes, take the first group it is part of
const textElement = excalidrawRef
.current
.getSceneElements()
.filter((el:any)=>el.groupIds?.includes(group))
.filter((el:any)=>el.type=="text"); //filter for text elements of the group
if(textElement.length==0) return {id:null,text:null}; //the group had no text element member
return {id:selectedElement[0].id, text:selectedElement[0].text}; //return text element text
};
this.addText = (text:string, fontFamily?:1|2|3) => {
if(!excalidrawRef?.current) {
return;
}
const el: ExcalidrawElement[] = excalidrawRef.current.getSceneElements();
const st: AppState = excalidrawRef.current.getAppState();
window.ExcalidrawAutomate.reset();
window.ExcalidrawAutomate.style.strokeColor = st.currentItemStrokeColor;
window.ExcalidrawAutomate.style.opacity = st.currentItemOpacity;
window.ExcalidrawAutomate.style.fontFamily = fontFamily ? fontFamily: st.currentItemFontFamily;
window.ExcalidrawAutomate.style.fontSize = st.currentItemFontSize;
window.ExcalidrawAutomate.style.textAlign = st.currentItemTextAlign;
const id:string = window.ExcalidrawAutomate.addText(currentPosition.x, currentPosition.y, text);
this.addElements(window.ExcalidrawAutomate.getElements(),false,true);
}
this.addElements = async (newElements:ExcalidrawElement[],repositionToCursor:boolean = false, save:boolean=false):Promise<boolean> => {
if(!excalidrawRef?.current) return false;
const textElements = newElements.filter((el)=>el.type=="text");
for(let i=0;i<textElements.length;i++) {
//@ts-ignore
const parseResult = await this.excalidrawData.addTextElement(textElements[i].id,textElements[i].text);
if(this.textMode==TextMode.parsed) {
this.excalidrawData.updateTextElement(textElements[i],parseResult);
}
};
const el: ExcalidrawElement[] = excalidrawRef.current.getSceneElements();
const st: AppState = excalidrawRef.current.getAppState();
if(repositionToCursor) newElements = repositionElementsToCursor(newElements,currentPosition,true);
this.excalidrawRef.current.updateScene({
elements: el.concat(newElements),
appState: st,
commitToHistory: true,
});
if(save) this.save(); else this.dirty = this.file?.path;
return true;
};
this.getScene = () => {
if(!excalidrawRef?.current) {
return null;
}
const el: ExcalidrawElement[] = excalidrawRef.current.getSceneElements();
const st: AppState = excalidrawRef.current.getAppState();
return {
type: "excalidraw",
version: 2,
source: "https://excalidraw.com",
elements: el,
appState: {
theme: st.theme,
viewBackgroundColor: st.viewBackgroundColor,
currentItemStrokeColor: st.currentItemStrokeColor,
currentItemBackgroundColor: st.currentItemBackgroundColor,
currentItemFillStyle: st.currentItemFillStyle,
currentItemStrokeWidth: st.currentItemStrokeWidth,
currentItemStrokeStyle: st.currentItemStrokeStyle,
currentItemRoughness: st.currentItemRoughness,
currentItemOpacity: st.currentItemOpacity,
currentItemFontFamily: st.currentItemFontFamily,
currentItemFontSize: st.currentItemFontSize,
currentItemTextAlign: st.currentItemTextAlign,
currentItemStrokeSharpness: st.currentItemStrokeSharpness,
currentItemStartArrowhead: st.currentItemStartArrowhead,
currentItemEndArrowhead: st.currentItemEndArrowhead,
currentItemLinearStrokeSharpness: st.currentItemLinearStrokeSharpness,
gridSize: st.gridSize,
}
};
};
this.refresh = () => {
if(!excalidrawRef?.current) return;
excalidrawRef.current.refresh();
};
//variables used to handle click events in view mode
let selectedTextElement:{id:string,text:string} = null;
let timestamp = 0;
let blockOnMouseButtonDown = false;
const getTextElementAtPointer = (pointer:any) => {
const elements = this.excalidrawRef.current.getSceneElements()
.filter((e:ExcalidrawElement)=>{
if (e.type !== "text") return false;
const [x,y,w,h] = rotatedDimensions(e);
return x<=pointer.x && x+w>=pointer.x
&& y<=pointer.y && y+h>=pointer.y;
});
if(elements.length==0) return null;
if(elements.length===1) return {id:elements[0].id,text:elements[0].text};
//if more than 1 text elements are at the location, look for one that has a link
const elementsWithLinks = elements.filter((e:ExcalidrawTextElement)=> {
const text:string = (this.textMode == TextMode.parsed)
? this.excalidrawData.getRawText(e.id)
: e.text;
if(!text) return false;
if(text.match(REG_LINKINDEX_HYPERLINK)) return true;
const parts = REGEX_LINK.getRes(text).next();
if(!parts.value) return false;
return true;
});
//if there are no text elements with links, return the first element without a link
if(elementsWithLinks.length==0) return {id:elements[0].id,text:elements[0].text};
//if there are still multiple text elements with links on top of each other, return the first
return {id:elementsWithLinks[0].id,text:elementsWithLinks[0].text};
}
let hoverPoint = {x:0,y:0};
let hoverPreviewTarget:EventTarget = null;
const clearHoverPreview = () => {
if(hoverPreviewTarget) {
const event = new MouseEvent('click', {
'view': window,
'bubbles': true,
'cancelable': true,
});
hoverPreviewTarget.dispatchEvent(event);
hoverPreviewTarget = null;
}
}
const dropAction = (transfer: DataTransfer) => {
// Return a 'copy' or 'link' action according to the content types, or undefined if no recognized type
//if (transfer.types.includes('text/uri-list')) return 'link';
let files = (this.app as any).dragManager.draggable?.files;
if(files) {
if(files[0] == this.file) {
files.shift();
(this.app as any).dragManager.draggable.title = files.length + " files";
}
}
if (['file', 'files'].includes((this.app as any).dragManager.draggable?.type)) return 'link';
if (transfer.types?.includes('text/html') || transfer.types?.includes('text/plain')) return 'copy';
}
let viewModeEnabled = false;
const handleLinkClick = () => {
selectedTextElement = getTextElementAtPointer(currentPosition);
if(selectedTextElement) {
const event = new MouseEvent("click", {ctrlKey: true, shiftKey: this.shiftKeyDown, altKey:this.altKeyDown});
this.handleLinkClick(this,event);
selectedTextElement = null;
}
}
let mouseEvent:any = null;
const excalidrawDiv = React.createElement(
"div",
{
className: "excalidraw-wrapper",
ref: excalidrawWrapperRef,
key: "abc",
tabIndex: 0,
onKeyDown: (e:any) => {
//@ts-ignore
if(e.target === excalidrawDiv.ref.current) return; //event should originate from the canvas
if(document.fullscreenEnabled && document.fullscreenElement == this.contentEl && e.keyCode==27) {
document.exitFullscreen();
this.zoomToFit();
}
this.ctrlKeyDown = e.ctrlKey || e.metaKey;
this.shiftKeyDown = e.shiftKey;
this.altKeyDown = e.altKey;
if(e.ctrlKey && !e.shiftKey && !e.altKey) { // && !e.metaKey) {
const selectedElement = getTextElementAtPointer(currentPosition);
if(!selectedElement) return;
const text:string = (this.textMode == TextMode.parsed)
? this.excalidrawData.getRawText(selectedElement.id)
: selectedElement.text;
if(!text) return;
if(text.match(REG_LINKINDEX_HYPERLINK)) return;
const parts = REGEX_LINK.getRes(text).next();
if(!parts.value) return;
let linktext = REGEX_LINK.getLink(parts); //parts.value[2] ? parts.value[2]:parts.value[6];
if(linktext.match(REG_LINKINDEX_HYPERLINK)) return;
this.plugin.hover.linkText = linktext;
this.plugin.hover.sourcePath = this.file.path;
hoverPreviewTarget = this.contentEl; //e.target;
this.app.workspace.trigger('hover-link', {
event: mouseEvent,
source: VIEW_TYPE_EXCALIDRAW,
hoverParent: hoverPreviewTarget,
targetEl: hoverPreviewTarget,
linktext: this.plugin.hover.linkText,
sourcePath: this.plugin.hover.sourcePath
});
hoverPoint = currentPosition;
if(document.fullscreenElement === this.contentEl) {
const self = this;
setTimeout(()=>{
const popover = document.body.querySelector("div.popover");
if(popover) self.contentEl.append(popover);
},100)
}
}
},
onKeyUp: (e:any) => {
this.ctrlKeyDown = e.ctrlKey || e.metaKey;
this.shiftKeyDown = e.shiftKey;
this.altKeyDown = e.altKey;
},
onClick: (e:MouseEvent):any => {
//@ts-ignore
if(!(e.ctrlKey||e.metaKey)) return;
if(!(this.plugin.settings.allowCtrlClick)) return;
if(!this.getSelectedTextElement().id) return;
this.handleLinkClick(this,e);
},
onMouseMove: (e:MouseEvent) => {
//@ts-ignore
mouseEvent = e.nativeEvent;
},
onMouseOver: (e:MouseEvent) => {
clearHoverPreview();
},
onDragOver: (e:any) => {
const action = dropAction(e.dataTransfer);
if (action) {
e.dataTransfer.dropEffect = action;
e.preventDefault();
return false;
}
},
onDragLeave: () => { },
},
React.createElement(Excalidraw.default, {
ref: excalidrawRef,
width: dimensions.width,
height: dimensions.height,
UIOptions: {
canvasActions: {
loadScene: false,
saveScene: false,
saveAsScene: false,
export: { saveFileToDisk: false },
saveAsImage: false,
saveToActiveFile: false,
},
},
initialData: initdata,
detectScroll: true,
onPointerUpdate: (p:any) => {
currentPosition = p.pointer;
if(hoverPreviewTarget && (Math.abs(hoverPoint.x-p.pointer.x)>50 || Math.abs(hoverPoint.y-p.pointer.y)>50)) clearHoverPreview();
if(!viewModeEnabled) return;
const buttonDown = !blockOnMouseButtonDown && p.button === "down";
if(buttonDown) {
blockOnMouseButtonDown = true;
//ctrl click
if(this.ctrlKeyDown) {
handleLinkClick();
return;
}
//dobule click
const now = (new Date()).getTime();
if(now-timestamp < 600) {
handleLinkClick();
}
timestamp = now;
return;
}
if (p.button === "up") {
blockOnMouseButtonDown=false;
}
},
onChange: (et:ExcalidrawElement[],st:AppState) => {
viewModeEnabled = st.viewModeEnabled;
if(this.justLoaded) {
this.justLoaded = false;
this.zoomToFit(false);
previousSceneVersion = getSceneVersion(et);
return;
}
if (st.editingElement == null && st.resizingElement == null &&
st.draggingElement == null && st.editingGroupId == null &&
st.editingLinearElement == null ) {
const sceneVersion = getSceneVersion(et);
if(sceneVersion != previousSceneVersion) {
previousSceneVersion = sceneVersion;
this.dirty=this.file?.path;
}
}
},
onLibraryChange: (items:LibraryItems) => {
(async () => {
this.plugin.setStencilLibrary(EXCALIDRAW_LIB_HEADER+JSON.stringify(items)+'}');
await this.plugin.saveSettings();
})();
},
onPaste: (data: ClipboardData, event: ClipboardEvent | null) => {
if(data.elements) {
const self = this;
setTimeout(()=>self.save(false),300);
}
return true;
},
onDrop: (event: React.DragEvent<HTMLDivElement>):boolean => {
const st: AppState = excalidrawRef.current.getAppState();
currentPosition = viewportCoordsToSceneCoords({ clientX: event.clientX, clientY: event.clientY },st);
const draggable = (this.app as any).dragManager.draggable;
const onDropHook = (type:"file"|"text"|"unknown", files:TFile[], text:string):boolean => {
if (window.ExcalidrawAutomate.onDropHook) {
try {
return window.ExcalidrawAutomate.onDropHook({
//@ts-ignore
ea: window.ExcalidrawAutomate, //the Excalidraw Automate object
event: event, //React.DragEvent<HTMLDivElement>
draggable: draggable, //Obsidian draggable object
type: type, //"file"|"text"
payload: {
files: files, //TFile[] array of dropped files
text: text, //string
},
excalidrawFile: this.file, //the file receiving the drop event
view: this, //the excalidraw view receiving the drop
pointerPosition: currentPosition //the pointer position on canvas at the time of drop
});
} catch (e) {
new Notice("on drop hook error. See console log for details");
console.log(e);
return false;
}
} else {
return false;
}
}
switch(draggable?.type) {
case "file":
if (!onDropHook("file",[draggable.file],null)) {
this.addText(`[[${this.app.metadataCache.fileToLinktext(draggable.file,this.file.path,true)}]]`);
}
return false;
case "files":
if (!onDropHook("file",draggable.files,null)) {
for(const f of draggable.files) {
this.addText(`[[${this.app.metadataCache.fileToLinktext(f,this.file.path,true)}]]`);
currentPosition.y+=st.currentItemFontSize*2;
}
}
return false;
}
if (event.dataTransfer.types.includes("text/plain")) {
const text:string = event.dataTransfer.getData("text");
if(!text) return true;
if (!onDropHook("text",null,text)) {
this.addText(text.replace(/(!\[\[.*#[^\]]*\]\])/g,"$1{40}"));
}
return false;
}
if(onDropHook("unknown",null,null)) return false;
return true;
},
onBeforeTextEdit: (textElement: ExcalidrawTextElement) => {
if(this.autosaveTimer) { //stopping autosave to avoid autosave overwriting text while the user edits it
clearInterval(this.autosaveTimer);
this.autosaveTimer = null;
}
//if(this.textMode==TextMode.parsed) {
const raw = this.excalidrawData.getRawText(textElement.id);
if(!raw) return textElement.rawText;
return raw;
/*}
return null;*/
},
onBeforeTextSubmit: (textElement: ExcalidrawTextElement, text:string, isDeleted:boolean) => {
if(isDeleted) {
this.excalidrawData.deleteTextElement(textElement.id);
this.dirty=this.file?.path;
this.setupAutosaveTimer();
return;
}
//If the parsed text is different than the raw text, and if View is in TextMode.parsed
//Then I need to clear the undo history to avoid overwriting raw text with parsed text and losing links
if(text!=textElement.text || !this.excalidrawData.getRawText(textElement.id)) { //the user made changes to the text or the text is missing from Excalidraw Data (recently copy/pasted)
//setTextElement will attempt a quick parse (without processing transclusions)
const parseResult = this.excalidrawData.setTextElement(textElement.id, text,async ()=>{
await this.save(false);
//this callback function will only be invoked if quick parse fails, i.e. there is a transclusion in the raw text
//thus I only check if TextMode.parsed, text is always != with parseResult
if(this.textMode == TextMode.parsed) this.excalidrawRef.current.history.clear();
this.setupAutosaveTimer();
});
if(parseResult) { //there were no transclusions in the raw text, quick parse was successful
this.setupAutosaveTimer();
if(this.textMode == TextMode.raw) return; //text is displayed in raw, no need to clear the history, undo will not create problems
if(text == parseResult) return; //There were no links to parse, raw text and parsed text are equivalent
this.excalidrawRef.current.history.clear();
return parseResult;
}
return;
}
this.setupAutosaveTimer();
if(this.textMode==TextMode.parsed) return this.excalidrawData.getParsedText(textElement.id);
}
})
);
return React.createElement(
React.Fragment,
null,
excalidrawDiv
);
});
ReactDOM.render(reactElement,this.contentEl,()=>this.excalidrawWrapperRef.current.focus());
}
public zoomToFit(delay:boolean = true) {
if(!this.excalidrawRef) return;
const current = this.excalidrawRef.current;
const fullscreen = (document.fullscreenElement==this.contentEl);
const elements = current.getSceneElements();
if(delay) { //time for the DOM to render, I am sure there is a more elegant solution
setTimeout(() => current.zoomToFit(elements,2,fullscreen?0:0.05),100);
} else {
current.zoomToFit(elements,2,fullscreen?0:0.05);
}
}
public static async getSVG(scene:any, exportSettings:ExportSettings):Promise<SVGSVGElement> {
try {
return exportToSvg({
elements: scene.elements,
appState: {
exportBackground: exportSettings.withBackground,
exportWithDarkMode: exportSettings.withTheme ? (scene.appState?.theme=="light" ? false : true) : false,
... scene.appState,},
exportPadding:10,
});
} catch (error) {
return null;
}
}
public static async getPNG(scene:any, exportSettings:ExportSettings, scale:number = 1) {
try {
return await Excalidraw.exportToBlob({
elements: scene.elements,
appState: {
exportBackground: exportSettings.withBackground,
exportWithDarkMode: exportSettings.withTheme ? (scene.appState?.theme=="light" ? false : true) : false,
... scene.appState,},
mimeType: "image/png",
exportWithDarkMode: "true",
metadata: "Generated by Excalidraw-Obsidian plugin",
getDimensions: (width:number, height:number) => ({ width:width*scale, height:height*scale, scale:scale })
});
} catch (error) {
return null;
}
}
}