mirror of
https://github.com/zsviczian/obsidian-excalidraw-plugin.git
synced 2025-08-06 05:46:28 +00:00
2.11.0-beta-1, 0.18.0-12 : added screenshot feature
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-excalidraw-plugin",
|
||||
"name": "Excalidraw",
|
||||
"version": "2.10.2-beta-1",
|
||||
"version": "2.11.0-beta-1",
|
||||
"minAppVersion": "1.1.6",
|
||||
"description": "An Obsidian plugin to edit and view Excalidraw drawings",
|
||||
"author": "Zsolt Viczian",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@zsviczian/excalidraw": "0.18.0-9",
|
||||
"@zsviczian/excalidraw": "0.18.0-12",
|
||||
"chroma-js": "^2.4.2",
|
||||
"clsx": "^2.0.0",
|
||||
"@zsviczian/colormaster": "^1.2.2",
|
||||
|
||||
@@ -1100,6 +1100,15 @@ FILENAME_HEAD: "Filename",
|
||||
EXPORTDIALOG_PDF_PROGRESS_DONE: "Export complete",
|
||||
EXPORTDIALOG_PDF_PROGRESS_ERROR: "Error exporting PDF, check developer console for details",
|
||||
|
||||
// Screenshot tab
|
||||
EXPORTDIALOG_TAB_SCREENSHOT: "Screenshot",
|
||||
EXPORTDIALOG_SCREENSHOT_DESC: "Screenshots will include embeddables such as markdown pages, YouTube, websites, etc. They are only available on desktop, cannot be automatically exported, and only support PNG format.",
|
||||
SCREENSHOT_DESKTOP_ONLY: "Screenshot feature is only available on desktop",
|
||||
SCREENSHOT_FILE_SUCCESS: "Screenshot saved to vault",
|
||||
SCREENSHOT_CLIPBOARD_SUCCESS: "Screenshot copied to clipboard",
|
||||
SCREENSHOT_CLIPBOARD_ERROR: "Failed to copy screenshot to clipboard: ",
|
||||
SCREENSHOT_ERROR: "Error capturing screenshot - see console log",
|
||||
|
||||
//exportUtils.ts
|
||||
PDF_EXPORT_DESKTOP_ONLY: "PDF export is only available on desktop",
|
||||
};
|
||||
|
||||
@@ -6,9 +6,11 @@ import { ExcalidrawAutomate } from "src/shared/ExcalidrawAutomate";
|
||||
import ExcalidrawView from "src/view/ExcalidrawView";
|
||||
import ExcalidrawPlugin from "src/core/main";
|
||||
import { fragWithHTML, getExportPadding, getExportTheme, getPNGScale, getWithBackground, shouldEmbedScene } from "src/utils/utils";
|
||||
import { PageOrientation, PageSize, PDFPageAlignment, PDFPageMarginString, exportSVGToClipboard } from "src/utils/exportUtils";
|
||||
import { PageOrientation, PageSize, PDFPageAlignment, PDFPageMarginString, exportSVGToClipboard, exportPNG, exportPNGToClipboard } from "src/utils/exportUtils";
|
||||
import { t } from "src/lang/helpers";
|
||||
import { PDFExportSettings, PDFExportSettingsComponent } from "./PDFExportSettingsComponent";
|
||||
import { captureScreenshot } from "src/utils/screenshot";
|
||||
import { createOrOverwriteFile, getIMGFilename } from "src/utils/fileUtils";
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +36,7 @@ export class ExportDialog extends Modal {
|
||||
public saveToVault: boolean;
|
||||
public pageSize: PageSize = "A4";
|
||||
public pageOrientation: PageOrientation = "portrait";
|
||||
private activeTab: "image" | "pdf" = "image";
|
||||
private activeTab: "image" | "pdf" | "screenshot" = "image";
|
||||
private contentContainer: HTMLDivElement;
|
||||
private buttonContainerRow1: HTMLDivElement;
|
||||
private buttonContainerRow2: HTMLDivElement;
|
||||
@@ -126,11 +128,17 @@ export class ExportDialog extends Modal {
|
||||
cls: `nav-button ${this.activeTab === "pdf" ? "is-active" : ""}`
|
||||
});
|
||||
|
||||
const screenshotTab = tabContainer.createEl("button", {
|
||||
text: t("EXPORTDIALOG_TAB_SCREENSHOT"),
|
||||
cls: `nav-button ${this.activeTab === "screenshot" ? "is-active" : ""}`
|
||||
});
|
||||
|
||||
// Tab click handlers
|
||||
imageTab.onclick = () => {
|
||||
this.activeTab = "image";
|
||||
imageTab.addClass("is-active");
|
||||
pdfTab.removeClass("is-active");
|
||||
screenshotTab.removeClass("is-active");
|
||||
this.renderContent();
|
||||
};
|
||||
|
||||
@@ -138,8 +146,17 @@ export class ExportDialog extends Modal {
|
||||
this.activeTab = "pdf";
|
||||
pdfTab.addClass("is-active");
|
||||
imageTab.removeClass("is-active");
|
||||
screenshotTab.removeClass("is-active");
|
||||
this.renderContent();
|
||||
};
|
||||
|
||||
screenshotTab.onclick = () => {
|
||||
this.activeTab = "screenshot";
|
||||
screenshotTab.addClass("is-active");
|
||||
imageTab.removeClass("is-active");
|
||||
pdfTab.removeClass("is-active");
|
||||
this.renderContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Create content container
|
||||
@@ -170,14 +187,23 @@ export class ExportDialog extends Modal {
|
||||
this.buttonContainerRow1.empty();
|
||||
this.buttonContainerRow2.empty();
|
||||
|
||||
if (this.activeTab === "image") {
|
||||
this.createImageSettings();
|
||||
this.createExportSettings();
|
||||
this.createImageButtons();
|
||||
} else {
|
||||
this.createImageSettings();
|
||||
this.createPDFSettings();
|
||||
this.createPDFButton();
|
||||
this.createHeader();
|
||||
switch (this.activeTab) {
|
||||
case "pdf":
|
||||
this.createImageSettings();
|
||||
this.createPDFSettings();
|
||||
this.createPDFButton();
|
||||
break;
|
||||
case "screenshot":
|
||||
this.createImageSettings(true);
|
||||
this.createImageButtons(true);
|
||||
break;
|
||||
case "image":
|
||||
default:
|
||||
this.createImageSettings(false);
|
||||
this.createExportSettings();
|
||||
this.createImageButtons();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,12 +212,28 @@ export class ExportDialog extends Modal {
|
||||
const height = Math.round(this.scale*this.boundingBox.height + this.padding*2);
|
||||
return fragWithHTML(`${t("EXPORTDIALOG_SIZE_DESC")}<br>${t("EXPORTDIALOG_SCALE_VALUE")} <b>${this.scale}</b><br>${t("EXPORTDIALOG_IMAGE_SIZE")} <b>${width}x${height}</b>`);
|
||||
}
|
||||
|
||||
private createImageSettings() {
|
||||
let paddingSetting: Setting;
|
||||
|
||||
this.contentContainer.createEl("h1",{text: t("EXPORTDIALOG_IMAGE_SETTINGS")});
|
||||
this.contentContainer.createEl("p",{text: t("EXPORTDIALOG_IMAGE_DESC")})
|
||||
private createHeader() {
|
||||
switch (this.activeTab) {
|
||||
case "pdf":
|
||||
this.contentContainer.createEl("h1",{text: t("EXPORTDIALOG_PDF_SETTINGS")});
|
||||
//this.contentContainer.createEl("p",{text: t("EXPORTDIALOG_PDF_DESC")});
|
||||
break;
|
||||
case "screenshot":
|
||||
this.contentContainer.createEl("h1",{text: t("EXPORTDIALOG_TAB_SCREENSHOT")});
|
||||
this.contentContainer.createEl("p",{text: t("EXPORTDIALOG_SCREENSHOT_DESC")})
|
||||
break;
|
||||
case "image":
|
||||
default:
|
||||
this.contentContainer.createEl("h1",{text: t("EXPORTDIALOG_IMAGE_SETTINGS")});
|
||||
this.contentContainer.createEl("p",{text: t("EXPORTDIALOG_IMAGE_DESC")})
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private createImageSettings(isScreenshot: boolean = false) {
|
||||
let paddingSetting: Setting;
|
||||
|
||||
this.createSaveSettingsDropdown();
|
||||
|
||||
@@ -238,17 +280,19 @@ export class ExportDialog extends Modal {
|
||||
})
|
||||
)
|
||||
|
||||
new Setting(this.contentContainer)
|
||||
.setName(t("EXPORTDIALOG_BACKGROUND"))
|
||||
.addDropdown(dropdown =>
|
||||
dropdown
|
||||
.addOption("transparent", t("EXPORTDIALOG_BACKGROUND_TRANSPARENT"))
|
||||
.addOption("with-color", t("EXPORTDIALOG_BACKGROUND_USE_COLOR"))
|
||||
.setValue(this.transparent?"transparent":"with-color")
|
||||
.onChange(value => {
|
||||
this.transparent = value === "transparent";
|
||||
})
|
||||
)
|
||||
if(!isScreenshot) {
|
||||
new Setting(this.contentContainer)
|
||||
.setName(t("EXPORTDIALOG_BACKGROUND"))
|
||||
.addDropdown(dropdown =>
|
||||
dropdown
|
||||
.addOption("transparent", t("EXPORTDIALOG_BACKGROUND_TRANSPARENT"))
|
||||
.addOption("with-color", t("EXPORTDIALOG_BACKGROUND_USE_COLOR"))
|
||||
.setValue(this.transparent?"transparent":"with-color")
|
||||
.onChange(value => {
|
||||
this.transparent = value === "transparent";
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
this.selectedOnlySetting = new Setting(this.contentContainer)
|
||||
.setName(t("EXPORTDIALOG_SELECTED_ELEMENTS"))
|
||||
@@ -262,6 +306,8 @@ export class ExportDialog extends Modal {
|
||||
this.updateBoundingBox();
|
||||
})
|
||||
);
|
||||
//@ts-ignore
|
||||
this.selectedOnlySetting.setVisibility(this.hasSelectedElements);
|
||||
}
|
||||
|
||||
private createExportSettings() {
|
||||
@@ -308,14 +354,29 @@ export class ExportDialog extends Modal {
|
||||
).render();
|
||||
}
|
||||
|
||||
private createImageButtons() {
|
||||
private createImageButtons(isScreenshot: boolean = false) {
|
||||
if(DEVICE.isDesktop) {
|
||||
const bPNG = this.buttonContainerRow1.createEl("button", {
|
||||
text: t("EXPORTDIALOG_PNGTOFILE"),
|
||||
cls: "excalidraw-export-button"
|
||||
});
|
||||
bPNG.onclick = () => {
|
||||
this.view.exportPNG(this.embedScene, this.hasSelectedElements && this.exportSelectedOnly);
|
||||
if(isScreenshot) {
|
||||
//allow dialot to close before taking screenshot
|
||||
setTimeout(async () => {
|
||||
const png = await captureScreenshot(this.view, {
|
||||
zoom: this.scale,
|
||||
margin: this.padding,
|
||||
selectedOnly: this.exportSelectedOnly,
|
||||
theme: this.theme
|
||||
});
|
||||
if(png) {
|
||||
exportPNG(png, this.view.file.basename);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.view.exportPNG(this.embedScene, this.hasSelectedElements && this.exportSelectedOnly);
|
||||
}
|
||||
this.close();
|
||||
};
|
||||
}
|
||||
@@ -325,7 +386,22 @@ export class ExportDialog extends Modal {
|
||||
cls: "excalidraw-export-button"
|
||||
});
|
||||
bPNGVault.onclick = () => {
|
||||
this.view.savePNG(this.view.getScene(this.hasSelectedElements && this.exportSelectedOnly));
|
||||
if(isScreenshot) {
|
||||
//allow dialot to close before taking screenshot
|
||||
setTimeout(async () => {
|
||||
const png = await captureScreenshot(this.view, {
|
||||
zoom: this.scale,
|
||||
margin: this.padding,
|
||||
selectedOnly: this.exportSelectedOnly,
|
||||
theme: this.theme
|
||||
});
|
||||
if(png) {
|
||||
createOrOverwriteFile(this.app, getIMGFilename(this.view.file.path,"png"), png);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.view.savePNG(this.view.getScene(this.hasSelectedElements && this.exportSelectedOnly));
|
||||
}
|
||||
this.close();
|
||||
};
|
||||
|
||||
@@ -334,10 +410,27 @@ export class ExportDialog extends Modal {
|
||||
cls: "excalidraw-export-button"
|
||||
});
|
||||
bPNGClipboard.onclick = async () => {
|
||||
this.view.exportPNGToClipboard(this.embedScene, this.hasSelectedElements && this.exportSelectedOnly);
|
||||
if(isScreenshot) {
|
||||
//allow dialot to close before taking screenshot
|
||||
setTimeout(async () => {
|
||||
const png = await captureScreenshot(this.view, {
|
||||
zoom: this.scale,
|
||||
margin: this.padding,
|
||||
selectedOnly: this.exportSelectedOnly,
|
||||
theme: this.theme
|
||||
});
|
||||
if(png) {
|
||||
exportPNGToClipboard(png);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.view.exportPNGToClipboard(this.embedScene, this.hasSelectedElements && this.exportSelectedOnly);
|
||||
}
|
||||
this.close();
|
||||
};
|
||||
|
||||
if(isScreenshot) return;
|
||||
|
||||
if(DEVICE.isDesktop) {
|
||||
const bExcalidraw = this.buttonContainerRow2.createEl("button", {
|
||||
text: t("EXPORTDIALOG_EXCALIDRAW"),
|
||||
|
||||
@@ -17,11 +17,9 @@ I build this plugin in my free time, as a labor of love. Curious about the philo
|
||||
|
||||
<div class="ex-coffee-div"><a href="https://ko-fi.com/zsolt"><img src="https://storage.ko-fi.com/cdn/kofi6.png?v=6" border="0" alt="Buy Me a Coffee at ko-fi.com" height=45></a></div>
|
||||
`,
|
||||
"2.10.2": `
|
||||
## Fixed by Excalidraw.com
|
||||
- Alt-duplicate now preserves the original element. Previously, using Alt to duplicate would swap the original with the new element, leading to unexpected behavior and several downstream issues. [#9403](https://github.com/excalidraw/excalidraw/pull/9403)
|
||||
|
||||
"2.11.0": `
|
||||
## New
|
||||
- New "Screenshot" option in the Export Image dialog. This allows you to take a screenshot of the current view, including embedded web pages, youtube videos, and markdown documents. Screenshot is only possible in PNG.
|
||||
- Expose parameter in plugin settings to disable AI functionality [#2325](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/2325)
|
||||
- Enable double-click text editing option in Excalidraw appearance and behavior (based on request on Discord)
|
||||
- Added two new PDF export sizes: "Match image", "HD Screen".
|
||||
@@ -29,8 +27,13 @@ I build this plugin in my free time, as a labor of love. Curious about the philo
|
||||
## Fixed in the plugin
|
||||
- Scaling multiple embeddables at once did not work. [#2276](https://github.com/zsviczian/obsidian-excalidraw-plugin/issues/2276)
|
||||
- When creating multiple back-of-the-note the second card is not created correctly if autosave has not yet happened.
|
||||
- Drawing reloads while editing the back-of-the-note card in certain cases causing editing to be interrupted.
|
||||
- Moved Excalidraw filetype indicator ✏️ to after filename where other filetype tags are displayed. You can turn filetype indicator on/off in plugin settings under Miscellaneous.
|
||||
- Drawing reloads while editing the back-of-the-note card in certain cases causes editing to be interrupted.
|
||||
- Moved Excalidraw filetype indicator ✏️ to after filename where other filetype tags are displayed. You can turn the filetype indicator on/off in plugin settings under Miscellaneous.
|
||||
|
||||
## Fixed by Excalidraw.com
|
||||
- Alt-duplicate now preserves the original element. Previously, using Alt to duplicate would swap the original with the new element, leading to unexpected behavior and several downstream issues. [#9403](https://github.com/excalidraw/excalidraw/pull/9403)
|
||||
- When dragging the arrow endpoint, update the binding only on the dragged side [#9367](https://github.com/excalidraw/excalidraw/pull/9367)
|
||||
- Laser pointer trail disappearing on pointerup [#9413](https://github.com/excalidraw/excalidraw/pull/9413) [#9427](https://github.com/excalidraw/excalidraw/pull/9427)
|
||||
`,
|
||||
"2.10.1": `
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Notice } from 'obsidian';
|
||||
import { DEVICE } from 'src/constants/constants';
|
||||
import { t } from 'src/lang/helpers';
|
||||
import { download } from './fileUtils';
|
||||
import { svgToBase64 } from './utils';
|
||||
|
||||
const DPI = 96;
|
||||
|
||||
@@ -512,4 +514,30 @@ export async function exportSVGToClipboard(svg: SVGSVGElement) {
|
||||
} catch (error) {
|
||||
console.error("Failed to copy SVG to clipboard: ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportPNGToClipboard(png: Blob) {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
"image/png": png,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export function exportPNG(png: Blob, filename: string) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(png);
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result;
|
||||
download(null, base64data, `${filename}.png`);
|
||||
};
|
||||
}
|
||||
|
||||
export function exportSVG(svg: SVGSVGElement, filename: string) {
|
||||
download(
|
||||
null,
|
||||
svgToBase64(svg.outerHTML),
|
||||
`${filename}.svg`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -404,8 +404,8 @@ export const getAliasWithSize = (alias: string, size: string): string => {
|
||||
}
|
||||
|
||||
export const getCropFileNameAndFolder = async (plugin: ExcalidrawPlugin, hostPath: string, baseNewFileName: string):Promise<{folderpath: string, filename: string}> => {
|
||||
let prefix = plugin.settings.cropPrefix;
|
||||
if(!prefix || prefix.trim() === "") prefix = CROPPED_PREFIX;
|
||||
let prefix = plugin.settings.cropPrefix || "";
|
||||
if(prefix.trim() === "") prefix = CROPPED_PREFIX;
|
||||
const filename = prefix + baseNewFileName + ".md";
|
||||
if(!plugin.settings.cropFolder || plugin.settings.cropFolder.trim() === "") {
|
||||
const folderpath = (await getAttachmentsFolderAndFilePath(plugin.app, hostPath, filename)).folder;
|
||||
@@ -417,8 +417,8 @@ export const getCropFileNameAndFolder = async (plugin: ExcalidrawPlugin, hostPat
|
||||
}
|
||||
|
||||
export const getAnnotationFileNameAndFolder = async (plugin: ExcalidrawPlugin, hostPath: string, baseNewFileName: string):Promise<{folderpath: string, filename: string}> => {
|
||||
let prefix = plugin.settings.annotatePrefix;
|
||||
if(!prefix || prefix.trim() === "") prefix = ANNOTATED_PREFIX;
|
||||
let prefix = plugin.settings.annotatePrefix || "";
|
||||
if(prefix.trim() === "") prefix = ANNOTATED_PREFIX;
|
||||
const filename = prefix + baseNewFileName + ".md";
|
||||
if(!plugin.settings.annotateFolder || plugin.settings.annotateFolder.trim() === "") {
|
||||
const folderpath = (await getAttachmentsFolderAndFilePath(plugin.app, hostPath, filename)).folder;
|
||||
@@ -494,8 +494,11 @@ export const hasExcalidrawEmbeddedImagesTreeChanged = (sourceFile: TFile, mtime:
|
||||
return fileList.some(f=>f.stat.mtime > mtime);
|
||||
}
|
||||
|
||||
export async function createOrOverwriteFile(app: App, path: string, content: string | ArrayBuffer): Promise<TFile> {
|
||||
export async function createOrOverwriteFile(app: App, path: string, content: string | ArrayBuffer | Blob): Promise<TFile> {
|
||||
const file = app.vault.getAbstractFileByPath(normalizePath(path));
|
||||
if (content instanceof Blob) {
|
||||
content = await content.arrayBuffer();
|
||||
}
|
||||
if(content instanceof ArrayBuffer) {
|
||||
if(file && file instanceof TFile) {
|
||||
await app.vault.modifyBinary(file, content);
|
||||
|
||||
273
src/utils/screenshot.ts
Normal file
273
src/utils/screenshot.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { ExcalidrawImperativeAPI } from "@zsviczian/excalidraw/types/excalidraw/types";
|
||||
import { request } from "http";
|
||||
import { Notice } from "obsidian";
|
||||
import { DEVICE } from "src/constants/constants";
|
||||
import { getEA } from "src/core";
|
||||
import { t } from "src/lang/helpers";
|
||||
import { ExcalidrawAutomate } from "src/shared/ExcalidrawAutomate";
|
||||
import ExcalidrawView from "src/view/ExcalidrawView";
|
||||
|
||||
export interface ScreenshotOptions {
|
||||
zoom: number;
|
||||
margin: number;
|
||||
selectedOnly: boolean;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
export async function captureScreenshot(view: ExcalidrawView, options: ScreenshotOptions): Promise<Blob | null> {
|
||||
if (!DEVICE.isDesktop) {
|
||||
new Notice(t("SCREENSHOT_DESKTOP_ONLY"));
|
||||
return null;
|
||||
}
|
||||
|
||||
(view.excalidrawAPI as ExcalidrawImperativeAPI).setForceRenderAllEmbeddables(true);
|
||||
|
||||
const remote = window.require("electron").remote;
|
||||
const scene = view.getScene();
|
||||
const viewSelectedElements = view.getViewSelectedElements();
|
||||
const selectedIDs = new Set(options.selectedOnly ? viewSelectedElements.map(el => el.id) : []);
|
||||
const savedOpacity: { id: string; opacity: number }[] = [];
|
||||
const ea = getEA(view) as ExcalidrawAutomate;
|
||||
|
||||
// Save the current browser zoom level
|
||||
const webContents = remote.getCurrentWebContents();
|
||||
const originalZoomFactor = webContents.getZoomFactor();
|
||||
|
||||
// Set browser zoom to 100%
|
||||
webContents.setZoomFactor(1.0);
|
||||
await sleep(100); // Give the browser time to apply zoom
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
|
||||
if (options.selectedOnly) {
|
||||
ea.copyViewElementsToEAforEditing(ea.getViewElements().filter(el => !selectedIDs.has(el.id)));
|
||||
ea.getElements().forEach(el => {
|
||||
savedOpacity.push({
|
||||
id: el.id,
|
||||
opacity: el.opacity
|
||||
});
|
||||
el.opacity = 0;
|
||||
});
|
||||
if (savedOpacity.length > 0) {
|
||||
await ea.addElementsToView(false, false, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
let boundingBox = ea.getBoundingBox(options.selectedOnly ? viewSelectedElements : scene.elements);
|
||||
boundingBox = {
|
||||
topX: Math.ceil(boundingBox.topX),
|
||||
topY: Math.ceil(boundingBox.topY),
|
||||
width: Math.ceil(boundingBox.width),
|
||||
height: Math.ceil(boundingBox.height)
|
||||
}
|
||||
|
||||
const margin = options.margin;
|
||||
const availableWidth = Math.floor(view.excalidrawAPI.getAppState().width);
|
||||
const availableHeight = Math.floor(view.excalidrawAPI.getAppState().height);
|
||||
|
||||
// Apply zoom to the total dimensions
|
||||
const totalWidth = Math.ceil(boundingBox.width * options.zoom + margin * 2);
|
||||
const totalHeight = Math.ceil(boundingBox.height * options.zoom + margin * 2);
|
||||
|
||||
// Calculate number of tiles
|
||||
const cols = Math.ceil(totalWidth / availableWidth);
|
||||
const rows = Math.ceil(totalHeight / availableHeight);
|
||||
|
||||
// Use exact tile sizes to avoid rounding issues
|
||||
const tileWidth = Math.ceil(totalWidth / cols);
|
||||
const tileHeight = Math.ceil(totalHeight / rows);
|
||||
|
||||
// Adjust totalWidth and totalHeight to be multiples of tileWidth and tileHeight
|
||||
const adjustedTotalWidth = tileWidth * cols;
|
||||
const adjustedTotalHeight = tileHeight * rows;
|
||||
|
||||
// Save and set state
|
||||
const saveState = () => {
|
||||
const {
|
||||
scrollX,
|
||||
scrollY,
|
||||
zoom,
|
||||
viewModeEnabled,
|
||||
linkOpacity,
|
||||
theme,
|
||||
} = view.excalidrawAPI.getAppState();
|
||||
return {
|
||||
scrollX,
|
||||
scrollY,
|
||||
zoom,
|
||||
viewModeEnabled,
|
||||
linkOpacity,
|
||||
theme,
|
||||
};
|
||||
}
|
||||
|
||||
const restoreState = (st: any) => {
|
||||
view.updateScene({
|
||||
appState: {
|
||||
...st
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const savedState = saveState();
|
||||
|
||||
// Switch to view mode for layerUIWrapper to be rendered so it can be hidden
|
||||
view.updateScene({
|
||||
appState: {
|
||||
viewModeEnabled: true,
|
||||
linkOpacity: 0,
|
||||
theme: options.theme,
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(50);
|
||||
|
||||
// Hide UI elements (must be after changing to view mode)
|
||||
const container = view.excalidrawWrapperRef.current;
|
||||
const layerUIWrapper = container.querySelector(".layer-ui__wrapper");
|
||||
const layerUIWrapperOriginalDisplay = layerUIWrapper.style.display;
|
||||
layerUIWrapper.style.display = "none";
|
||||
|
||||
const originalStyle = {
|
||||
width: container.style.width,
|
||||
height: container.style.height,
|
||||
left: container.style.left,
|
||||
top: container.style.top,
|
||||
position: container.style.position,
|
||||
overflow: container.style.overflow,
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
container.style.width = tileWidth + "px";
|
||||
container.style.height = tileHeight + "px";
|
||||
container.style.overflow = "visible";
|
||||
|
||||
// Set canvas size and zoom value for capture
|
||||
view.updateScene({
|
||||
appState: {
|
||||
zoom: {
|
||||
value: options.zoom
|
||||
},
|
||||
width: tileWidth,
|
||||
height: tileHeight
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(50); // wait for frame to render
|
||||
|
||||
// Prepare to collect tile images as data URLs
|
||||
const rect = container.getBoundingClientRect();
|
||||
const tiles = [];
|
||||
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
// Calculate scroll position for this tile (adjusted for zoom)
|
||||
const scrollX = boundingBox.topX - margin / options.zoom + (col * tileWidth) / options.zoom;
|
||||
const scrollY = boundingBox.topY - margin / options.zoom + (row * tileHeight) / options.zoom;
|
||||
|
||||
view.updateScene({
|
||||
appState: {
|
||||
scrollX: -scrollX,
|
||||
scrollY: -scrollY,
|
||||
zoom: {
|
||||
value: options.zoom
|
||||
},
|
||||
width: tileWidth,
|
||||
height: tileHeight
|
||||
},
|
||||
});
|
||||
|
||||
await sleep(250);
|
||||
|
||||
// Calculate the exact width/height for this tile
|
||||
const captureWidth = col === cols - 1 ? adjustedTotalWidth - tileWidth * (cols - 1) : tileWidth;
|
||||
const captureHeight = row === rows - 1 ? adjustedTotalHeight - tileHeight * (rows - 1) : tileHeight;
|
||||
|
||||
const image = await remote.getCurrentWebContents().capturePage({
|
||||
x: rect.left * devicePixelRatio,
|
||||
y: rect.top * devicePixelRatio,
|
||||
width: captureWidth * devicePixelRatio,
|
||||
height: captureHeight * devicePixelRatio,
|
||||
});
|
||||
|
||||
tiles.push({
|
||||
url: "data:image/png;base64," + image.toPNG().toString("base64"),
|
||||
width: captureWidth,
|
||||
height: captureHeight,
|
||||
col: col,
|
||||
row: row
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original styles
|
||||
Object.assign(container.style, originalStyle);
|
||||
|
||||
// Stitch tiles together using a browser canvas
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = adjustedTotalWidth * devicePixelRatio;
|
||||
canvas.height = adjustedTotalHeight * devicePixelRatio;
|
||||
canvas.style.width = `${adjustedTotalWidth}px`;
|
||||
canvas.style.height = `${adjustedTotalHeight}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.scale(1, 1);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
let y = 0;
|
||||
for (let row = 0; row < rows; row++) {
|
||||
let x = 0;
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const tile = tiles[row * cols + col];
|
||||
const img = new window.Image();
|
||||
img.src = tile.url;
|
||||
await new Promise(res => {
|
||||
img.onload = res;
|
||||
});
|
||||
ctx.drawImage(img, x, y);
|
||||
x += tile.width * devicePixelRatio;
|
||||
}
|
||||
y += tiles[row * cols].height * devicePixelRatio; // Use the height of the first tile in the row
|
||||
}
|
||||
|
||||
// Return the blob for the caller to handle
|
||||
return new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
}, "image/png");
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
new Notice(t("SCREENSHOT_ERROR"));
|
||||
return null;
|
||||
} finally {
|
||||
// Restore browser zoom to its original value
|
||||
webContents.setZoomFactor(originalZoomFactor);
|
||||
|
||||
// Restore UI elements
|
||||
try {
|
||||
const container = view.excalidrawWrapperRef.current;
|
||||
const layerUIWrapper = container.querySelector(".layer-ui__wrapper");
|
||||
if (layerUIWrapper) {
|
||||
layerUIWrapper.style.display = layerUIWrapperOriginalDisplay;
|
||||
}
|
||||
|
||||
// Restore original state
|
||||
restoreState(savedState);
|
||||
|
||||
// Restore opacity for selected elements if necessary
|
||||
if (options.selectedOnly && savedOpacity.length > 0) {
|
||||
ea.clear();
|
||||
ea.copyViewElementsToEAforEditing(ea.getViewElements().filter(el => !selectedIDs.has(el.id)));
|
||||
savedOpacity.forEach(x => {
|
||||
ea.getElement(x.id).opacity = x.opacity;
|
||||
});
|
||||
await ea.addElementsToView(false, false, false, false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error in cleanup:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ import { getPDFCropRect } from "../utils/PDFUtils";
|
||||
import { Position, ViewSemaphores } from "../types/excalidrawViewTypes";
|
||||
import { DropManager } from "./managers/DropManager";
|
||||
import { ImageInfo } from "src/types/excalidrawAutomateTypes";
|
||||
import { exportToPDF, getMarginValue, getPageDimensions, PageOrientation, PageSize } from "src/utils/exportUtils";
|
||||
import { exportPNG, exportPNGToClipboard, exportSVG, exportToPDF, getMarginValue, getPageDimensions, PageOrientation, PageSize } from "src/utils/exportUtils";
|
||||
import { FrameRenderingOptions } from "src/types/utilTypes";
|
||||
import { CaptureUpdateAction } from "src/constants/constants";
|
||||
|
||||
@@ -581,11 +581,7 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{
|
||||
if (!svg) {
|
||||
return;
|
||||
}
|
||||
download(
|
||||
null,
|
||||
svgToBase64(svg.outerHTML),
|
||||
`${this.file.basename}.svg`,
|
||||
);
|
||||
exportSVG(svg, this.file.basename);
|
||||
}
|
||||
|
||||
public async getSVG(embedScene?: boolean, selectedOnly?: boolean):Promise<SVGSVGElement> {
|
||||
@@ -685,7 +681,7 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{
|
||||
if (!png) {
|
||||
return;
|
||||
}
|
||||
await createOrOverwriteFile(this.app, filepath, await png.arrayBuffer());
|
||||
await createOrOverwriteFile(this.app, filepath, png);
|
||||
}
|
||||
|
||||
if(this.plugin.settings.autoExportLightAndDark) {
|
||||
@@ -714,11 +710,7 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{
|
||||
//
|
||||
// not await so that we can detect whether the thrown error likely relates
|
||||
// to a lack of support for the Promise ClipboardItem constructor
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
"image/png": png,
|
||||
}),
|
||||
]);
|
||||
await exportPNGToClipboard(png);
|
||||
}
|
||||
|
||||
public async exportPNG(embedScene?:boolean, selectedOnly?: boolean):Promise<void> {
|
||||
@@ -731,12 +723,7 @@ export default class ExcalidrawView extends TextFileView implements HoverParent{
|
||||
if (!png) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(png);
|
||||
reader.onloadend = () => {
|
||||
const base64data = reader.result;
|
||||
download(null, base64data, `${this.file.basename}.png`);
|
||||
};
|
||||
exportPNG(png, this.file.basename);
|
||||
}
|
||||
|
||||
public setPreventReload() {
|
||||
|
||||
Reference in New Issue
Block a user