diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 687c319e7d..e0b5f30816 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -21,6 +21,17 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
+
+
+
+
+
= 20"
+ }
+ },
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
diff --git a/src/app/core/share/share.model.ts b/src/app/core/share/share.model.ts
index 619a31143f..719115d87a 100644
--- a/src/app/core/share/share.model.ts
+++ b/src/app/core/share/share.model.ts
@@ -25,7 +25,8 @@ export type ShareTarget =
| 'email'
| 'mastodon'
| 'clipboard-text'
- | 'native';
+ | 'native'
+ | 'download';
/**
* Result of a share operation
@@ -39,6 +40,10 @@ export interface ShareResult {
target?: ShareTarget;
/** Whether native share was attempted */
usedNative?: boolean;
+ /** Optional local filesystem path for download results */
+ path?: string;
+ /** Optional URI for native downloads */
+ uri?: string;
}
/**
@@ -66,3 +71,30 @@ export interface ShareDialogOptions {
/** Pre-selected Mastodon instance */
mastodonInstance?: string;
}
+
+/**
+ * Options for sharing a canvas element as an image
+ */
+export interface ShareCanvasImageParams {
+ /** Canvas element that should be shared */
+ canvas: HTMLCanvasElement;
+ /** Output filename including extension */
+ filename?: string;
+ /** Optional title to use for share dialogs */
+ shareTitle?: string;
+ /** Optional tagline configuration appended below the canvas */
+ tagline?: ShareCanvasTagline;
+ /** Disable download fallback when sharing is unavailable */
+ fallbackToDownload?: boolean;
+ /** Sharing mode, defaults to auto */
+ mode?: 'auto' | 'download-only';
+}
+
+export interface ShareCanvasTagline {
+ /** Tagline text rendered below the canvas image */
+ text: string;
+ /** Optional tagline area height; defaults to 48 */
+ height?: number;
+ /** Optional text color */
+ color?: string;
+}
diff --git a/src/app/core/share/share.service.ts b/src/app/core/share/share.service.ts
index 89569c2568..317e190ff6 100644
--- a/src/app/core/share/share.service.ts
+++ b/src/app/core/share/share.service.ts
@@ -1,9 +1,17 @@
import { Injectable, inject } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Capacitor } from '@capacitor/core';
+import { Directory, Filesystem } from '@capacitor/filesystem';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { SnackService } from '../snack/snack.service';
-import { SharePayload, ShareResult, ShareTarget, ShareTargetConfig } from './share.model';
+import {
+ ShareCanvasImageParams,
+ ShareCanvasTagline,
+ SharePayload,
+ ShareResult,
+ ShareTarget,
+ ShareTargetConfig,
+} from './share.model';
const FALLBACK_SHARE_URL = 'https://super-productivity.com';
@@ -54,6 +62,54 @@ export class ShareService {
return 'failed';
}
+ canOpenDownloadResult(result: ShareResult): boolean {
+ if (!result) {
+ return false;
+ }
+
+ if (typeof window !== 'undefined' && result.path && (window as any).ea?.openPath) {
+ return true;
+ }
+
+ if (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW) {
+ return false;
+ }
+
+ if (typeof window !== 'undefined' && (result.uri || result.path)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ async openDownloadResult(result: ShareResult): Promise {
+ if (!result) {
+ return;
+ }
+
+ const { uri, path } = result;
+
+ if (typeof window !== 'undefined' && path && (window as any).ea?.openPath) {
+ try {
+ (window as any).ea.openPath(path);
+ return;
+ } catch (error) {
+ console.warn('Failed to open path via Electron bridge:', error);
+ }
+ }
+
+ const candidate = uri ?? path;
+
+ if (typeof window !== 'undefined' && candidate) {
+ try {
+ window.open(candidate, '_blank', 'noopener');
+ return;
+ } catch (error) {
+ console.warn('Failed to open download in new window:', error);
+ }
+ }
+ }
+
async getShareSupport(): Promise {
if (typeof window === 'undefined') {
return 'none';
@@ -115,6 +171,66 @@ export class ShareService {
}
}
+ /**
+ * Share a canvas element as an image, supporting native, web, and download fallbacks.
+ */
+ async shareCanvasImage(params: ShareCanvasImageParams): Promise {
+ if (typeof window === 'undefined' || !params?.canvas) {
+ return {
+ success: false,
+ error: 'Canvas not available',
+ };
+ }
+
+ const filename = this._sanitizeFilename(params.filename ?? 'shared-image.png');
+ const shareTitle = params.shareTitle ?? filename;
+ const canvasForExport = this._prepareCanvasForShare(params.canvas, params.tagline);
+ const dataUrl = canvasForExport.toDataURL('image/png', 1.0);
+ const base64 = this._extractBase64(dataUrl);
+ const blob = await this._canvasToBlob(canvasForExport);
+ if (!blob) {
+ return {
+ success: false,
+ error: 'Failed to export image',
+ };
+ }
+
+ if (params.mode === 'download-only') {
+ return this._saveCanvasDownload({
+ blob,
+ base64,
+ filename,
+ dataUrl,
+ });
+ }
+
+ if (base64) {
+ const nativeResult = await this._shareCanvasViaNative(base64, filename, shareTitle);
+ if (nativeResult.success || nativeResult.error === 'Share cancelled') {
+ return nativeResult;
+ }
+ }
+
+ const webResult = await this._shareCanvasViaWeb(blob, filename, shareTitle, dataUrl);
+ if (webResult.success || webResult.error === 'Share cancelled') {
+ return webResult;
+ }
+
+ if (params.fallbackToDownload !== false) {
+ return this._saveCanvasDownload({
+ blob,
+ base64,
+ filename,
+ dataUrl,
+ });
+ }
+
+ return {
+ success: false,
+ error: 'Share not available',
+ };
+ }
+
/**
* Try to use native share (Android, Web Share API).
* Public API for dialog component.
@@ -617,6 +733,343 @@ export class ShareService {
return text.replace(/\p{Extended_Pictographic}|\uFE0F|\uFE0E|\u200D/gu, '');
}
+ private _prepareCanvasForShare(
+ canvas: HTMLCanvasElement,
+ tagline?: ShareCanvasTagline,
+ ): HTMLCanvasElement {
+ if (!tagline?.text) {
+ return canvas;
+ }
+
+ const taglineHeight = Math.max(1, tagline.height ?? 48);
+ const newCanvas = document.createElement('canvas');
+ newCanvas.width = canvas.width;
+ newCanvas.height = canvas.height + taglineHeight;
+
+ const ctx = newCanvas.getContext('2d');
+ if (!ctx) {
+ return canvas;
+ }
+
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
+ ctx.drawImage(canvas, 0, 0);
+
+ ctx.fillStyle = tagline.color ?? 'rgba(0, 0, 0, 0.6)';
+ const baseFontSize = Math.max(20, Math.round(newCanvas.width / 40));
+ ctx.font = `${baseFontSize}px system-ui, -apple-system, sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ const taglineOffset = taglineHeight / 2;
+ const taglineY = canvas.height + taglineOffset;
+ ctx.fillText(tagline.text, newCanvas.width / 2, taglineY);
+
+ return newCanvas;
+ }
+
+ private _canvasToBlob(canvas: HTMLCanvasElement): Promise {
+ return new Promise((resolve) => {
+ canvas.toBlob((blob) => resolve(blob), 'image/png', 1.0);
+ });
+ }
+
+ private async _shareCanvasViaNative(
+ base64: string,
+ filename: string,
+ title: string,
+ ): Promise {
+ const sharePlugin = await this._getCapacitorSharePlugin();
+ if (!sharePlugin) {
+ return {
+ success: false,
+ error: 'Native share not available',
+ };
+ }
+
+ const sanitizedName = this._sanitizeFilename(filename);
+ const relativePath = `shared-images/${Date.now()}-${sanitizedName}`;
+ let fileUrl: string | null = null;
+ let resolvedUri: string | null = null;
+
+ try {
+ const writeResult = await Filesystem.writeFile({
+ path: relativePath,
+ data: base64,
+ directory: Directory.Cache,
+ recursive: true,
+ });
+
+ resolvedUri =
+ writeResult.uri ??
+ (
+ await Filesystem.getUri({
+ directory: Directory.Cache,
+ path: relativePath,
+ })
+ ).uri;
+
+ if (!resolvedUri) {
+ throw new Error('Failed to resolve native share uri');
+ }
+
+ fileUrl = resolvedUri.startsWith('file://')
+ ? resolvedUri
+ : resolvedUri.startsWith('/')
+ ? `file://${resolvedUri}`
+ : `file:///${resolvedUri}`;
+
+ console.debug('[ShareService] shareCanvasViaNative', {
+ resolvedUri,
+ fileUrl,
+ relativePath,
+ });
+
+ try {
+ const stat = await Filesystem.stat({
+ path: relativePath,
+ directory: Directory.Cache,
+ });
+ console.debug('[ShareService] shareCanvasViaNative stat', stat);
+ } catch (statError) {
+ console.warn('[ShareService] stat failed for shared image', statError);
+ }
+
+ const canShare = (await sharePlugin.canShare?.())?.value ?? true;
+ if (!canShare) {
+ return {
+ success: false,
+ error: 'Native share not available',
+ };
+ }
+
+ await sharePlugin.share({
+ title,
+ text: '',
+ files: [fileUrl],
+ dialogTitle: 'Share via',
+ });
+
+ this._snackService.open('Shared successfully!');
+ this._scheduleCacheCleanup(relativePath);
+ return {
+ success: true,
+ usedNative: true,
+ target: 'native',
+ };
+ } catch (error: any) {
+ if (error?.name === 'AbortError' || /Share canceled/i.test(error?.message)) {
+ return {
+ success: false,
+ error: 'Share cancelled',
+ };
+ }
+ console.warn('Native image share failed:', error, {
+ fileUrl: fileUrl ?? 'n/a',
+ resolvedUri: resolvedUri ?? 'n/a',
+ relativePath,
+ });
+ this._scheduleCacheCleanup(relativePath);
+ return {
+ success: false,
+ error: 'Native share failed',
+ };
+ }
+ }
+
+ private async _shareCanvasViaWeb(
+ blob: Blob,
+ filename: string,
+ title: string,
+ dataUrl: string,
+ ): Promise {
+ if (typeof navigator === 'undefined') {
+ return {
+ success: false,
+ error: 'Share not available',
+ };
+ }
+
+ try {
+ if (typeof File !== 'undefined') {
+ const file = new File([blob], filename, { type: 'image/png' });
+ if (navigator.canShare && navigator.canShare({ files: [file] })) {
+ await navigator.share({
+ files: [file],
+ title,
+ });
+ this._snackService.open('Shared successfully!');
+ return {
+ success: true,
+ usedNative: true,
+ target: 'native',
+ };
+ }
+ }
+
+ if (typeof navigator.share === 'function') {
+ await navigator.share({
+ title,
+ url: dataUrl,
+ });
+ this._snackService.open('Shared successfully!');
+ return {
+ success: true,
+ usedNative: true,
+ target: 'native',
+ };
+ }
+ } catch (error: any) {
+ if (error?.name === 'AbortError') {
+ return {
+ success: false,
+ error: 'Share cancelled',
+ };
+ }
+ console.warn('Web Share API failed:', error);
+ }
+
+ return {
+ success: false,
+ error: 'Share not available',
+ };
+ }
+
+ private async _saveCanvasDownload({
+ blob,
+ base64,
+ filename,
+ dataUrl,
+ }: {
+ blob: Blob;
+ base64?: string | null;
+ filename: string;
+ dataUrl: string;
+ }): Promise {
+ if (base64 && (Capacitor.isNativePlatform() || IS_ANDROID_WEB_VIEW)) {
+ const sanitizedName = this._sanitizeFilename(filename);
+ const relativePath = `shared-images/${Date.now()}-${sanitizedName}`;
+ try {
+ const writeResult = await Filesystem.writeFile({
+ path: relativePath,
+ data: base64,
+ directory: Directory.Documents,
+ recursive: true,
+ });
+
+ const uri =
+ writeResult.uri ??
+ (
+ await Filesystem.getUri({
+ directory: Directory.Documents,
+ path: relativePath,
+ })
+ ).uri;
+
+ const storedPath = `Documents/${relativePath}`;
+
+ return {
+ success: true,
+ target: 'download',
+ usedNative: true,
+ path: storedPath,
+ uri,
+ };
+ } catch (error) {
+ console.warn('Native download failed, falling back to browser download:', error);
+ }
+ }
+
+ const downloaded = this._downloadBlob(blob, filename);
+ if (downloaded) {
+ return {
+ success: true,
+ target: 'download',
+ };
+ }
+
+ if (typeof window !== 'undefined' && dataUrl) {
+ try {
+ window.open(dataUrl, '_blank');
+ return {
+ success: true,
+ target: 'download',
+ };
+ } catch (error) {
+ console.warn('Opening image in new tab failed:', error);
+ }
+ }
+
+ return {
+ success: false,
+ error: 'Download failed',
+ };
+ }
+
+ private _downloadBlob(blob: Blob, filename: string): boolean {
+ if (typeof document === 'undefined') {
+ return false;
+ }
+
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement('a');
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ try {
+ anchor.click();
+ return true;
+ } catch (error) {
+ console.warn('Browser download failed:', error);
+ return false;
+ } finally {
+ document.body.removeChild(anchor);
+ URL.revokeObjectURL(url);
+ }
+ }
+
+ private async _cleanupCacheFile(relativePath: string): Promise {
+ if (!relativePath) {
+ return;
+ }
+
+ try {
+ await Filesystem.deleteFile({
+ path: relativePath,
+ directory: Directory.Cache,
+ });
+ } catch (cleanupError) {
+ console.warn('Failed to cleanup shared file:', cleanupError);
+ }
+ }
+
+ private _scheduleCacheCleanup(relativePath: string): void {
+ if (!relativePath) {
+ return;
+ }
+
+ setTimeout(() => {
+ void this._cleanupCacheFile(relativePath);
+ }, 15_000);
+ }
+
+ private _sanitizeFilename(filename: string): string {
+ const trimmed = filename.trim() || 'shared-image.png';
+ const sanitized = trimmed.replace(/[^a-zA-Z0-9._-]/g, '_');
+ if (!sanitized.toLowerCase().endsWith('.png')) {
+ return `${sanitized}.png`;
+ }
+ return sanitized;
+ }
+
+ private _extractBase64(dataUrl: string): string | null {
+ const commaIndex = dataUrl.indexOf(',');
+ if (commaIndex === -1) {
+ return null;
+ }
+
+ return dataUrl.slice(commaIndex + 1);
+ }
+
private async _detectShareSupport(): Promise {
if (await this._isCapacitorShareAvailable()) {
return 'native';
@@ -642,15 +1095,24 @@ export class ShareService {
}
private async _getCapacitorSharePlugin(): Promise {
- if (!Capacitor.isNativePlatform() || typeof window === 'undefined') {
+ if (typeof window === 'undefined') {
return null;
}
const win = window as any;
- const sharePlugin = win.Capacitor?.Plugins?.Share;
- if (sharePlugin && typeof sharePlugin.share === 'function') {
- return sharePlugin;
+ if (Capacitor.isNativePlatform()) {
+ const nativePlugin = win.Capacitor?.Plugins?.Share;
+ if (nativePlugin && typeof nativePlugin.share === 'function') {
+ return nativePlugin;
+ }
+ }
+
+ if (IS_ANDROID_WEB_VIEW) {
+ const webViewPlugin = win.Capacitor?.Plugins?.Share;
+ if (webViewPlugin && typeof webViewPlugin.share === 'function') {
+ return webViewPlugin;
+ }
}
return null;
diff --git a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts
index 18f2a8a223..634e00ff96 100644
--- a/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts
+++ b/src/app/features/metric/activity-heatmap/activity-heatmap.component.ts
@@ -21,6 +21,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatIcon } from '@angular/material/icon';
import { SnackService } from '../../../core/snack/snack.service';
import { GlobalConfigService } from '../../config/global-config.service';
+import { ShareService } from '../../../core/share/share.service';
interface DayData {
date: Date;
@@ -48,6 +49,7 @@ export class ActivityHeatmapComponent {
private readonly _taskArchiveService = inject(TaskArchiveService);
private readonly _snackService = inject(SnackService);
private readonly _globalConfigService = inject(GlobalConfigService);
+ private readonly _shareService = inject(ShareService);
T: typeof T = T;
weeks: WeekData[] = [];
@@ -450,35 +452,43 @@ export class ActivityHeatmapComponent {
const contextTitle = this._activeWorkContextTitle();
const canvas = this._renderToCanvas(data, contextTitle);
- // Convert to blob
- const blob: Blob | null = await new Promise((resolve) => {
- canvas.toBlob((b) => resolve(b), 'image/png', 1.0);
+ const result = await this._shareService.shareCanvasImage({
+ canvas,
+ filename: 'activity-heatmap.png',
+ shareTitle: 'Activity Heatmap',
});
- if (!blob) {
- throw new Error('Failed to generate image');
- }
-
- const file = new File([blob], 'activity-heatmap.png', { type: 'image/png' });
-
- // Try native share API first
- if (navigator.canShare && navigator.canShare({ files: [file] })) {
- await navigator.share({
- files: [file],
- title: 'Activity Heatmap',
+ if (result.success) {
+ if (result.target === 'download') {
+ const message = result.path
+ ? `Heatmap saved to ${result.path}`
+ : 'Heatmap saved to device storage';
+ const canOpen = this._shareService.canOpenDownloadResult(result);
+ const actionConfig = canOpen
+ ? {
+ actionStr: T.GLOBAL_SNACK.FILE_DOWNLOADED_BTN,
+ actionFn: () => {
+ void this._shareService.openDownloadResult(result);
+ },
+ }
+ : {};
+ this._snackService.open({
+ type: 'SUCCESS',
+ msg: message,
+ isSkipTranslate: true,
+ ...actionConfig,
+ });
+ }
+ } else if (result.error && result.error !== 'Share cancelled') {
+ console.error('Share failed:', result.error);
+ this._snackService.open({
+ type: 'ERROR',
+ msg: 'Failed to share heatmap',
});
- } else {
- // Fallback: Download the file
- this._downloadFile(blob, 'activity-heatmap.png');
}
-
- this._snackService.open({
- type: 'SUCCESS',
- msg: 'Heatmap shared successfully',
- });
} catch (error: any) {
- // User cancelled or error occurred
- if (error?.name !== 'AbortError') {
+ const isAbort = error?.name === 'AbortError' || error?.error === 'Share cancelled';
+ if (!isAbort) {
console.error('Share failed:', error);
this._snackService.open({
type: 'ERROR',
@@ -629,15 +639,4 @@ export class ActivityHeatmapComponent {
ctx.closePath();
ctx.fill();
}
-
- private _downloadFile(blob: Blob, filename: string): void {
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = filename;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }
}
diff --git a/src/app/features/metric/lazy-chart/lazy-chart.component.ts b/src/app/features/metric/lazy-chart/lazy-chart.component.ts
index dfdc33db92..2385ed8cab 100644
--- a/src/app/features/metric/lazy-chart/lazy-chart.component.ts
+++ b/src/app/features/metric/lazy-chart/lazy-chart.component.ts
@@ -26,6 +26,9 @@ import { MatIconButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
+import { ShareService } from '../../../core/share/share.service';
+import { SnackService } from '../../../core/snack/snack.service';
+import { T } from '../../../t.const';
interface ChartClickEvent {
active: ActiveElement[];
@@ -120,6 +123,8 @@ export class LazyChartComponent implements OnInit, OnDestroy {
private readonly chartLoaderService = inject(ChartLazyLoaderService);
private readonly cdr = inject(ChangeDetectorRef);
+ private readonly shareService = inject(ShareService);
+ private readonly snackService = inject(SnackService);
isLoaded = false;
isSharing = false;
@@ -205,26 +210,39 @@ export class LazyChartComponent implements OnInit, OnDestroy {
this.cdr.markForCheck();
try {
- const decoratedCanvas = this._createCanvasWithTagline(this.chartInstance.canvas);
-
- const blob: Blob | null = await new Promise((resolve) => {
- decoratedCanvas.toBlob((b) => resolve(b), 'image/png', 1.0);
+ const result = await this.shareService.shareCanvasImage({
+ canvas: this.chartInstance.canvas,
+ filename: this.shareFileName,
+ shareTitle: this.shareFileName,
+ tagline: {
+ text: 'With the Super Productivity App',
+ },
});
- if (!blob) {
- throw new Error('Failed to export chart');
+ if (result.success && result.target === 'download') {
+ const message = result.path
+ ? `Chart image saved to ${result.path}`
+ : 'Chart image saved to device storage';
+ const canOpen = this.shareService.canOpenDownloadResult(result);
+ const actionConfig = canOpen
+ ? {
+ actionStr: T.GLOBAL_SNACK.FILE_DOWNLOADED_BTN,
+ actionFn: () => {
+ void this.shareService.openDownloadResult(result);
+ },
+ }
+ : {};
+
+ this.snackService.open({
+ type: 'SUCCESS',
+ msg: message,
+ isSkipTranslate: true,
+ ...actionConfig,
+ });
}
- const filename = this.shareFileName?.trim() || 'chart.png';
- const file = new File([blob], filename, { type: 'image/png' });
-
- if (navigator.canShare && navigator.canShare({ files: [file] })) {
- await navigator.share({
- files: [file],
- title: filename,
- });
- } else {
- this._downloadFile(blob, filename);
+ if (!result.success && result.error && result.error !== 'Share cancelled') {
+ console.error('Share failed:', result.error);
}
} catch (error) {
console.error('Share failed:', error);
@@ -233,45 +251,4 @@ export class LazyChartComponent implements OnInit, OnDestroy {
this.cdr.markForCheck();
}
}
-
- private _createCanvasWithTagline(sourceCanvas: HTMLCanvasElement): HTMLCanvasElement {
- const taglineHeight = 48;
- const newCanvas = document.createElement('canvas');
- newCanvas.width = sourceCanvas.width;
- newCanvas.height = sourceCanvas.height + taglineHeight;
-
- const ctx = newCanvas.getContext('2d');
- if (!ctx) {
- return sourceCanvas;
- }
-
- ctx.fillStyle = '#ffffff';
- ctx.fillRect(0, 0, newCanvas.width, newCanvas.height);
- ctx.drawImage(sourceCanvas, 0, 0);
-
- const shareLabel = `With the Super Productivity App`;
-
- ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
-
- const baseFontSize = Math.max(20, Math.round(newCanvas.width / 40));
- ctx.font = `${baseFontSize}px system-ui, -apple-system, sans-serif`;
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- const taglineOffset = taglineHeight / 2;
- const taglineY = sourceCanvas.height + taglineOffset;
- ctx.fillText(shareLabel, newCanvas.width / 2, taglineY);
-
- return newCanvas;
- }
-
- private _downloadFile(blob: Blob, filename: string): void {
- const url = URL.createObjectURL(blob);
- const anchor = document.createElement('a');
- anchor.href = url;
- anchor.download = filename;
- document.body.appendChild(anchor);
- anchor.click();
- document.body.removeChild(anchor);
- URL.revokeObjectURL(url);
- }
}