diff --git a/package.json b/package.json index 0f41502..c313d7a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "author": "", "license": "MIT", "dependencies": { - "@zsviczian/excalidraw": "0.9.0-obsidian-11", + "@zsviczian/excalidraw": "0.9.0-obsidian-image-support-3", "monkey-around": "^2.2.0", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/src/ExcalidrawAutomate.ts b/src/ExcalidrawAutomate.ts index c300939..70955b0 100644 --- a/src/ExcalidrawAutomate.ts +++ b/src/ExcalidrawAutomate.ts @@ -15,9 +15,10 @@ import { FRONTMATTER, nanoid, JSON_parse, - VIEW_TYPE_EXCALIDRAW + VIEW_TYPE_EXCALIDRAW, + MAX_IMAGE_SIZE } from "./constants"; -import { wrapText } from "./Utils"; +import { getObsidianImage, wrapText } from "./Utils"; import { AppState } from "@zsviczian/excalidraw/types/types"; declare type ConnectionPoint = "top"|"bottom"|"left"|"right"; @@ -26,6 +27,7 @@ export interface ExcalidrawAutomate extends Window { ExcalidrawAutomate: { plugin: ExcalidrawPlugin; elementsDict: {}; + imagesDict: {}; style: { strokeColor: string; backgroundColor: string; @@ -102,6 +104,7 @@ export interface ExcalidrawAutomate extends Window { endObjectId?:string } ):string ; + addImage(topX:number, topY:number, imageFile: TFile):Promise; connectObjects ( objectA: string, connectionA: ConnectionPoint, @@ -160,6 +163,7 @@ export async function initExcalidrawAutomate(plugin: ExcalidrawPlugin) { window.ExcalidrawAutomate = { plugin: plugin, elementsDict: {}, + imagesDict: {}, style: { strokeColor: "#000000", backgroundColor: "transparent", @@ -513,6 +517,25 @@ export async function initExcalidrawAutomate(plugin: ExcalidrawPlugin) { } return id; }, + async addImage(topX:number, topY:number, imageFile: TFile):Promise { + const id = nanoid(); + const image = await getObsidianImage(this.plugin.app,imageFile); + if(!image) return null; + this.imagesDict[image.imageId] = { + type:"image", + id: image.imageId, + dataURL: image.dataURL + } + if (Math.max(image.size.width,image.size.height) > MAX_IMAGE_SIZE) { + const scale = MAX_IMAGE_SIZE/Math.max(image.size.width,image.size.height); + image.size.width = scale*image.size.width; + image.size.height = scale*image.size.height; + } + this.elementsDict[id] = boxedElement(id,"image",topX,topY,image.size.width,image.size.height); + this.elementsDict[id].imageId = image.imageId; + this.elementsDict[id].scale = [1,1]; + return id; + }, connectObjects(objectA: string, connectionA: ConnectionPoint, objectB: string, connectionB: ConnectionPoint, formatting?:{numberOfPoints?: number,startArrowHead?:string,endArrowHead?:string, padding?: number}):void { if(!(this.elementsDict[objectA] && this.elementsDict[objectB])) { return; @@ -552,6 +575,7 @@ export async function initExcalidrawAutomate(plugin: ExcalidrawPlugin) { }, clear() { this.elementsDict = {}; + this.imagesDict = {}; }, reset() { this.clear(); @@ -682,7 +706,7 @@ export async function initExcalidrawAutomate(plugin: ExcalidrawPlugin) { return false; } const elements = this.getElements(); - return await this.targetView.addElements(elements,repositionToCursor,save); + return await this.targetView.addElements(elements,repositionToCursor,save,this.imagesDict); }, onDropHook:null, }; diff --git a/src/ExcalidrawView.ts b/src/ExcalidrawView.ts index 5ec9e2c..8c96dd9 100644 --- a/src/ExcalidrawView.ts +++ b/src/ExcalidrawView.ts @@ -28,7 +28,8 @@ import { TEXT_DISPLAY_RAW_ICON_NAME, TEXT_DISPLAY_PARSED_ICON_NAME, FULLSCREEN_ICON_NAME, - JSON_parse + JSON_parse, + IMAGE_TYPES } from './constants'; import ExcalidrawPlugin from './main'; import {estimateBounds, ExcalidrawAutomate, repositionElementsToCursor} from './ExcalidrawAutomate'; @@ -459,6 +460,12 @@ export default class ExcalidrawView extends TextFileView { } setMarkdownView() { + if(this.excalidrawRef) { + const el = this.excalidrawRef.current.getSceneElements(); + if(el.filter((e:any)=>e.type==="image").length>0) { + new Notice(t("DRAWING_CONTAINS_IMAGE"),6000); + } + } this.plugin.excalidrawFileModes[this.id || this.file.path] = "markdown"; this.plugin.setMarkdownView(this.leaf); } @@ -640,7 +647,7 @@ export default class ExcalidrawView extends TextFileView { this.addElements(window.ExcalidrawAutomate.getElements(),false,true); } - this.addElements = async (newElements:ExcalidrawElement[],repositionToCursor:boolean = false, save:boolean=false):Promise => { + this.addElements = async (newElements:ExcalidrawElement[],repositionToCursor:boolean = false, save:boolean=false, images:any):Promise => { if(!excalidrawRef?.current) return false; const textElements = newElements.filter((el)=>el.type=="text"); @@ -653,7 +660,20 @@ export default class ExcalidrawView extends TextFileView { }; const el: ExcalidrawElement[] = excalidrawRef.current.getSceneElements(); - const st: AppState = excalidrawRef.current.getAppState(); + let st: AppState = excalidrawRef.current.getAppState(); + if(!st.files) { + st.files = {}; + } + if(images) { + Object.keys(images).forEach((k)=>{ + st.files[k]={ + type:images[k].type, + id: images[k].id, + dataURL: images[k].dataURL + } + }); + } + //merge appstate.files with files if(repositionToCursor) newElements = repositionElementsToCursor(newElements,currentPosition,true); this.excalidrawRef.current.updateScene({ elements: el.concat(newElements), @@ -670,6 +690,13 @@ export default class ExcalidrawView extends TextFileView { } const el: ExcalidrawElement[] = excalidrawRef.current.getSceneElements(); const st: AppState = excalidrawRef.current.getAppState(); + + if(st.files) { + const imgIds = el.filter((e)=>e.type=="image").map((e:any)=>e.imageId); + const toDelete = Object.keys(st.files).filter((k)=>!imgIds.contains(k)); + toDelete.forEach((k)=>delete st.files[k]); + } + return { type: "excalidraw", version: 2, @@ -693,6 +720,7 @@ export default class ExcalidrawView extends TextFileView { currentItemEndArrowhead: st.currentItemEndArrowhead, currentItemLinearStrokeSharpness: st.currentItemLinearStrokeSharpness, gridSize: st.gridSize, + files: st.files??{}, } }; }; @@ -947,6 +975,19 @@ export default class ExcalidrawView extends TextFileView { switch(draggable?.type) { case "file": if (!onDropHook("file",[draggable.file],null)) { + if((event.ctrlKey || event.metaKey) && IMAGE_TYPES.contains(draggable.file.extension)) { + const f = draggable.file; + const topX = currentPosition.x; + const topY = currentPosition.y; + const ea = window.ExcalidrawAutomate; + ea.reset(); + ea.setView(this); + (async () => { + await ea.addImage(currentPosition.x,currentPosition.y,draggable.file); + ea.addElementsToView(false,false); + })(); + return false; + } this.addText(`[[${this.app.metadataCache.fileToLinktext(draggable.file,this.file.path,true)}]]`); } return false; diff --git a/src/Utils.ts b/src/Utils.ts index ab499f5..a57352d 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -1,6 +1,9 @@ -import { normalizePath, TAbstractFile, TFolder, Vault } from "obsidian"; +import { App, normalizePath, TAbstractFile, TFile, TFolder, Vault } from "obsidian"; import { Random } from "roughjs/bin/math"; import { Zoom } from "@zsviczian/excalidraw/types/types"; +import { nanoid } from "nanoid"; +import { IMAGE_TYPES } from "./constants"; + /** * Splits a full path including a folderpath and a filename into separate folderpath and filename components @@ -122,4 +125,66 @@ export const viewportCoordsToSceneCoords = ( const x = (clientX - zoom.translation.x - offsetLeft) * invScale - scrollX; const y = (clientY - zoom.translation.y - offsetTop) * invScale - scrollY; return { x, y }; -}; \ No newline at end of file +}; + +export const getObsidianImage = async (app: App, file: TFile) + :Promise<{ + imageId: string, + dataURL: string, + size: {height: number, width: number}, + }> => { + if(!app || !file) return null; + if (!IMAGE_TYPES.contains(file.extension)) return null; + const ab = await app.vault.readBinary(file); + return { + imageId: await generateIdFromFile(ab), + dataURL: file.extension==="svg" ? await getSVGData(app,file) : await getDataURL(ab), + size: await getImageSize(app,file) + } +} + +const getSVGData = async (app: App, file: TFile): Promise => { + const svg = await app.vault.read(file); + return "data:image/svg+xml;base64,"+btoa(unescape(encodeURIComponent(svg.replaceAll(" "," ")))) +} + +const getDataURL = async (file: ArrayBuffer): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataURL = reader.result as string; + resolve(dataURL); + }; + reader.onerror = (error) => reject(error); + reader.readAsDataURL(new Blob([new Uint8Array(file)])); + }); +}; + +const generateIdFromFile = async (file: ArrayBuffer):Promise => { + let id: string; + try { + const hashBuffer = await window.crypto.subtle.digest( + "SHA-1", + file, + ); + id = + // convert buffer to byte array + Array.from(new Uint8Array(hashBuffer)) + // convert to hex string + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + } catch (error) { + console.error(error); + id = nanoid(40); + } + return id; +}; + +const getImageSize = async (app: App, file:TFile):Promise<{height:number, width:number}> => { + return new Promise((resolve, reject) => { + let img = new Image() + img.onload = () => resolve({height: img.height, width:img.width}); + img.onerror = reject; + img.src = app.vault.getResourcePath(file); + }) +} diff --git a/src/constants.ts b/src/constants.ts index 0b0efd1..ac77759 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,8 @@ export function JSON_parse(x:string):any {return JSON.parse(x.replaceAll("[" import {customAlphabet} from "nanoid"; export const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',8); +export const IMAGE_TYPES = ['jpeg', 'jpg', 'png', 'gif', 'svg', 'bmp']; +export const MAX_IMAGE_SIZE = 600; export const FRONTMATTER_KEY = "excalidraw-plugin"; export const FRONTMATTER_KEY_CUSTOM_PREFIX = "excalidraw-link-prefix"; export const FRONTMATTER_KEY_CUSTOM_URL_PREFIX = "excalidraw-url-prefix"; diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index 315cc2c..bbc4374 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -44,6 +44,8 @@ export default { NOFILE: "Excalidraw (no file)", COMPATIBILITY_MODE: "*.excalidraw file opened in compatibility mode. Convert to new format for full plugin functionality.", CONVERT_FILE: "Convert to new format", + DRAWING_CONTAINS_IMAGE: "Warning! The drawing contains image elements. Depending on the number and size of the images, " + + "loading Markdown View may take a while. Please be patient. ", //settings.ts FOLDER_NAME: "Excalidraw folder", diff --git a/yarn.lock b/yarn.lock index 4fb84d3..935d9b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1024,10 +1024,10 @@ dependencies: "@types/estree" "*" -"@zsviczian/excalidraw@0.9.0-obsidian-11": - "integrity" "sha512-h4d8l0slwWB2yLaZnD1qSoYQ9eaZFWEe2Ls2rJYNIc6v9EAEAWMj/4NGRgOpcxdU4dKt5MSknHzezRsfeDz5bQ==" - "resolved" "https://registry.npmjs.org/@zsviczian/excalidraw/-/excalidraw-0.9.0-obsidian-11.tgz" - "version" "0.9.0-obsidian-11" +"@zsviczian/excalidraw@0.9.0-obsidian-image-support-3": + "integrity" "sha512-Y+hIhIxoNsoyYS2AmhE9I8G0M6Q6KaQ3LxSn8p3JZbUGFqoAmDg+26fGHXhMsboaVXhKqonkw5UN1UlZYL5k1A==" + "resolved" "https://registry.npmjs.org/@zsviczian/excalidraw/-/excalidraw-0.9.0-obsidian-image-support-3.tgz" + "version" "0.9.0-obsidian-image-support-3" "abab@^1.0.3": "integrity" "sha1-X6rZwsB/YN12dw9xzwJbYqY8/U4="