refactor copy/export, fix safari copy-as-image being broken (#2411)
In #2198, we introduced a regression on ios around image exports. ios will block copys if they're not called syncronously in a UI event. It's important to make ios copys like this: ```ts navigator.clipboard.write(new ClipboardItem(getStuffToCopyAsAPromise()) ``` instead of ```ts getStuffToCopyAsAPromise().then(stuff => { navigator.clipboard.write(new ClipboardItem(stuff)) }) ``` We've written and fixed this bug a few times, which i think is because of how our export/copy code is written: the formatting is interspersed with interacting with the browser APIs, which makes it hard to change one without accidentally affecting the other. This diff fixes the bug, but also restructures our export/copy code: all the formatting is handled by `exportToBlob` and related which just return `Blob`s. This leaves `copyAs`, `exportAs` etc. to just handle interacting with the browser APIs. Fixes #2312 ### Change Type - [x] `patch` — Bug fix ### Test Plan 1. Test copy/export as on all browsers ### Release Notes - Fix a bug preventing copying as an image on iOS --------- Co-authored-by: huppy-bot[bot] <128400622+huppy-bot[bot]@users.noreply.github.com>
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
@ -700,7 +700,7 @@ export function getResizedImageDataUrl(dataURLForImage: string, width: number, h
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export function getSvgAsImage(svg: SVGElement, isSafari: boolean, options: {
|
export function getSvgAsImage(svg: SVGElement, isSafari: boolean, options: {
|
||||||
type: 'jpeg' | 'png' | 'svg' | 'webp';
|
type: 'jpeg' | 'png' | 'webp';
|
||||||
quality: number;
|
quality: number;
|
||||||
scale: number;
|
scale: number;
|
||||||
}): Promise<Blob | null>;
|
}): Promise<Blob | null>;
|
||||||
|
|
|
@ -7799,7 +7799,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
"text": "{\n type: 'jpeg' | 'png' | 'svg' | 'webp';\n quality: number;\n scale: number;\n}"
|
"text": "{\n type: 'jpeg' | 'png' | 'webp';\n quality: number;\n scale: number;\n}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"kind": "Content",
|
"kind": "Content",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Editor, TLShapeId, TLSvgOptions } from '@tldraw/editor'
|
import { Editor, TLShapeId, TLSvgOptions, exhaustiveSwitchError } from '@tldraw/editor'
|
||||||
import { getSvgAsImage } from './export'
|
import { exportToBlobPromise, exportToString } from './export'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export type TLCopyType = 'svg' | 'png' | 'jpeg' | 'json'
|
export type TLCopyType = 'svg' | 'png' | 'jpeg' | 'json'
|
||||||
|
@ -19,121 +19,41 @@ export function copyAs(
|
||||||
ids: TLShapeId[],
|
ids: TLShapeId[],
|
||||||
format: TLCopyType = 'svg',
|
format: TLCopyType = 'svg',
|
||||||
opts = {} as Partial<TLSvgOptions>
|
opts = {} as Partial<TLSvgOptions>
|
||||||
) {
|
): Promise<void> {
|
||||||
// Note: it's important that this function itself isn't async - we need to create the relevant
|
// Note: it's important that this function itself isn't async and doesn't really use promises -
|
||||||
// `ClipboardItem`s synchronously to make sure safari knows that the user _wants_ to copy
|
// we need to create the relevant `ClipboardItem`s and call window.navigator.clipboard.write
|
||||||
// See https://bugs.webkit.org/show_bug.cgi?id=222262
|
// synchronously to make sure safari knows that the user _wants_ to copy See
|
||||||
|
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
||||||
|
|
||||||
return editor
|
if (!window.navigator.clipboard) return Promise.reject(new Error('Copy not supported'))
|
||||||
.getSvg(ids?.length ? ids : [...editor.getCurrentPageShapeIds()], {
|
if (window.navigator.clipboard.write) {
|
||||||
scale: 1,
|
const { blobPromise, mimeType } = exportToBlobPromise(editor, ids, format, opts)
|
||||||
background: editor.getInstanceState().exportBackground,
|
|
||||||
...opts,
|
|
||||||
})
|
|
||||||
.then((svg) => {
|
|
||||||
if (!svg) {
|
|
||||||
throw new Error('Could not construct SVG.')
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (format) {
|
return window.navigator.clipboard
|
||||||
case 'svg': {
|
.write([new ClipboardItem({ [mimeType]: blobPromise })])
|
||||||
if (window.navigator.clipboard) {
|
.catch((err) => {
|
||||||
if (window.navigator.clipboard.write) {
|
// Firefox will fail with the above if `dom.events.asyncClipboard.clipboardItem` is enabled.
|
||||||
window.navigator.clipboard.write([
|
// See <https://github.com/tldraw/tldraw/issues/1325>
|
||||||
new ClipboardItem({
|
console.error(err)
|
||||||
'text/plain': new Blob([getSvgAsString(svg)], { type: 'text/plain' }),
|
return blobPromise.then((blob) => {
|
||||||
}),
|
return window.navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
|
||||||
])
|
})
|
||||||
} else {
|
})
|
||||||
fallbackWriteTextAsync(async () => getSvgAsString(svg))
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'jpeg':
|
switch (format) {
|
||||||
case 'png': {
|
case 'json':
|
||||||
// Note: This needs to use the promise based approach for safari/ios to not bail on a permissions error.
|
case 'svg':
|
||||||
const blobPromise = getSvgAsImage(svg, editor.environment.isSafari, {
|
return fallbackWriteTextAsync(async () => exportToString(editor, ids, format, opts))
|
||||||
type: format,
|
|
||||||
quality: 1,
|
|
||||||
scale: 2,
|
|
||||||
}).then((blob) => {
|
|
||||||
if (blob) {
|
|
||||||
if (window.navigator.clipboard) {
|
|
||||||
return blob
|
|
||||||
}
|
|
||||||
throw new Error('Copy not supported')
|
|
||||||
} else {
|
|
||||||
throw new Error('Copy not possible')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'
|
case 'jpeg':
|
||||||
if (window.navigator.clipboard.write) {
|
case 'png':
|
||||||
window.navigator.clipboard
|
throw new Error('Copy not supported')
|
||||||
.write([
|
default:
|
||||||
new ClipboardItem({
|
exhaustiveSwitchError(format)
|
||||||
[mimeType]: blobPromise,
|
}
|
||||||
}),
|
|
||||||
])
|
|
||||||
.catch((err: any) => {
|
|
||||||
// Firefox will fail with the above if `dom.events.asyncClipboard.clipboardItem` is enabled.
|
|
||||||
// See <https://github.com/tldraw/tldraw/issues/1325>
|
|
||||||
if (!err.toString().match(/^TypeError: DOMString not supported/)) {
|
|
||||||
console.error(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blobPromise.then((blob) => {
|
|
||||||
window.navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
// Note: This needs to use the promise based approach for safari/ios to not bail on a permissions error.
|
|
||||||
[mimeType]: blob,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'json': {
|
|
||||||
const data = editor.getContentFromCurrentPage(ids)
|
|
||||||
const jsonStr = JSON.stringify(data)
|
|
||||||
|
|
||||||
if (window.navigator.clipboard.write) {
|
|
||||||
window.navigator.clipboard.write([
|
|
||||||
new ClipboardItem({
|
|
||||||
'text/plain': new Blob([jsonStr], { type: 'text/plain' }),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
} else {
|
|
||||||
fallbackWriteTextAsync(async () => jsonStr)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Copy type ${format} not supported.`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fallbackWriteTextAsync(getText: () => Promise<string>) {
|
async function fallbackWriteTextAsync(getText: () => Promise<string>) {
|
||||||
navigator.clipboard?.writeText?.(await getText())
|
await navigator.clipboard?.writeText?.(await getText())
|
||||||
}
|
|
||||||
|
|
||||||
function getSvgAsString(svg: SVGElement) {
|
|
||||||
const clone = svg.cloneNode(true) as SVGGraphicsElement
|
|
||||||
|
|
||||||
svg.setAttribute('width', +svg.getAttribute('width')! + '')
|
|
||||||
svg.setAttribute('height', +svg.getAttribute('height')! + '')
|
|
||||||
|
|
||||||
const out = new XMLSerializer()
|
|
||||||
.serializeToString(clone)
|
|
||||||
.replaceAll(' ', '')
|
|
||||||
.replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1')
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
import { PngHelpers, debugFlags } from '@tldraw/editor'
|
import {
|
||||||
|
Editor,
|
||||||
|
PngHelpers,
|
||||||
|
TLShapeId,
|
||||||
|
TLSvgOptions,
|
||||||
|
debugFlags,
|
||||||
|
exhaustiveSwitchError,
|
||||||
|
} from '@tldraw/editor'
|
||||||
import { getBrowserCanvasMaxSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
|
import { getBrowserCanvasMaxSize } from '../../shapes/shared/getBrowserCanvasMaxSize'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
|
@ -6,7 +13,7 @@ export async function getSvgAsImage(
|
||||||
svg: SVGElement,
|
svg: SVGElement,
|
||||||
isSafari: boolean,
|
isSafari: boolean,
|
||||||
options: {
|
options: {
|
||||||
type: 'svg' | 'png' | 'jpeg' | 'webp'
|
type: 'png' | 'jpeg' | 'webp'
|
||||||
quality: number
|
quality: number
|
||||||
scale: number
|
scale: number
|
||||||
}
|
}
|
||||||
|
@ -18,7 +25,8 @@ export async function getSvgAsImage(
|
||||||
let scaledWidth = width * scale
|
let scaledWidth = width * scale
|
||||||
let scaledHeight = height * scale
|
let scaledHeight = height * scale
|
||||||
|
|
||||||
const dataUrl = await getSvgAsDataUrl(svg)
|
const svgString = await getSvgAsString(svg)
|
||||||
|
const svgUrl = URL.createObjectURL(new Blob([svgString], { type: 'image/svg+xml' }))
|
||||||
|
|
||||||
const canvasSizes = await getBrowserCanvasMaxSize()
|
const canvasSizes = await getBrowserCanvasMaxSize()
|
||||||
if (width > canvasSizes.maxWidth) {
|
if (width > canvasSizes.maxWidth) {
|
||||||
|
@ -62,7 +70,7 @@ export async function getSvgAsImage(
|
||||||
ctx.imageSmoothingQuality = 'high'
|
ctx.imageSmoothingQuality = 'high'
|
||||||
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight)
|
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight)
|
||||||
|
|
||||||
URL.revokeObjectURL(dataUrl)
|
URL.revokeObjectURL(svgUrl)
|
||||||
|
|
||||||
resolve(canvas)
|
resolve(canvas)
|
||||||
}
|
}
|
||||||
|
@ -71,7 +79,7 @@ export async function getSvgAsImage(
|
||||||
resolve(null)
|
resolve(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
image.src = dataUrl
|
image.src = svgUrl
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!canvas) return null
|
if (!canvas) return null
|
||||||
|
@ -97,10 +105,11 @@ export async function getSvgAsImage(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @public */
|
async function getSvgAsString(svg: SVGElement) {
|
||||||
export async function getSvgAsDataUrl(svg: SVGElement) {
|
|
||||||
const clone = svg.cloneNode(true) as SVGGraphicsElement
|
const clone = svg.cloneNode(true) as SVGGraphicsElement
|
||||||
clone.setAttribute('encoding', 'UTF-8"')
|
|
||||||
|
svg.setAttribute('width', +svg.getAttribute('width')! + '')
|
||||||
|
svg.setAttribute('height', +svg.getAttribute('height')! + '')
|
||||||
|
|
||||||
const fileReader = new FileReader()
|
const fileReader = new FileReader()
|
||||||
const imgs = Array.from(clone.querySelectorAll('image')) as SVGImageElement[]
|
const imgs = Array.from(clone.querySelectorAll('image')) as SVGImageElement[]
|
||||||
|
@ -120,9 +129,96 @@ export async function getSvgAsDataUrl(svg: SVGElement) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const svgStr = new XMLSerializer().serializeToString(clone)
|
const out = new XMLSerializer()
|
||||||
// NOTE: `unescape` works everywhere although deprecated
|
.serializeToString(clone)
|
||||||
// eslint-disable-next-line deprecation/deprecation
|
.replaceAll(' ', '')
|
||||||
const base64SVG = window.btoa(unescape(encodeURIComponent(svgStr)))
|
.replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1')
|
||||||
return `data:image/svg+xml;base64,${base64SVG}`
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSvg(editor: Editor, ids: TLShapeId[], opts: Partial<TLSvgOptions>) {
|
||||||
|
const svg = await editor.getSvg(ids?.length ? ids : [...editor.getCurrentPageShapeIds()], {
|
||||||
|
scale: 1,
|
||||||
|
background: editor.getInstanceState().exportBackground,
|
||||||
|
...opts,
|
||||||
|
})
|
||||||
|
if (!svg) {
|
||||||
|
throw new Error('Could not construct SVG.')
|
||||||
|
}
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportToString(
|
||||||
|
editor: Editor,
|
||||||
|
ids: TLShapeId[],
|
||||||
|
format: 'svg' | 'json',
|
||||||
|
opts = {} as Partial<TLSvgOptions>
|
||||||
|
) {
|
||||||
|
switch (format) {
|
||||||
|
case 'svg': {
|
||||||
|
return getSvgAsString(await getSvg(editor, ids, opts))
|
||||||
|
}
|
||||||
|
case 'json': {
|
||||||
|
const data = editor.getContentFromCurrentPage(ids)
|
||||||
|
return JSON.stringify(data)
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
exhaustiveSwitchError(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportToBlob(
|
||||||
|
editor: Editor,
|
||||||
|
ids: TLShapeId[],
|
||||||
|
format: 'svg' | 'png' | 'jpeg' | 'webp' | 'json',
|
||||||
|
opts = {} as Partial<TLSvgOptions>
|
||||||
|
): Promise<Blob> {
|
||||||
|
switch (format) {
|
||||||
|
case 'svg':
|
||||||
|
return new Blob([await exportToString(editor, ids, 'svg', opts)], { type: 'text/plain' })
|
||||||
|
case 'json':
|
||||||
|
return new Blob([await exportToString(editor, ids, 'json', opts)], { type: 'text/plain' })
|
||||||
|
case 'jpeg':
|
||||||
|
case 'png':
|
||||||
|
case 'webp': {
|
||||||
|
const image = await getSvgAsImage(
|
||||||
|
await getSvg(editor, ids, opts),
|
||||||
|
editor.environment.isSafari,
|
||||||
|
{
|
||||||
|
type: format,
|
||||||
|
quality: 1,
|
||||||
|
scale: 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!image) {
|
||||||
|
throw new Error('Could not construct image.')
|
||||||
|
}
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
exhaustiveSwitchError(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeTypeByFormat = {
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
png: 'image/png',
|
||||||
|
webp: 'image/webp',
|
||||||
|
json: 'text/plain',
|
||||||
|
svg: 'text/plain',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToBlobPromise(
|
||||||
|
editor: Editor,
|
||||||
|
ids: TLShapeId[],
|
||||||
|
format: 'svg' | 'png' | 'jpeg' | 'webp' | 'json',
|
||||||
|
opts = {} as Partial<TLSvgOptions>
|
||||||
|
): { blobPromise: Promise<Blob>; mimeType: string } {
|
||||||
|
return {
|
||||||
|
blobPromise: exportToBlob(editor, ids, format, opts),
|
||||||
|
mimeType: mimeTypeByFormat[format],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Editor, TLFrameShape, TLShapeId, TLSvgOptions } from '@tldraw/editor'
|
import { Editor, TLFrameShape, TLShapeId, TLSvgOptions } from '@tldraw/editor'
|
||||||
import { getSvgAsDataUrl, getSvgAsImage } from './export'
|
import { exportToBlob } from './export'
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export type TLExportType = 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
|
export type TLExportType = 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
|
||||||
|
@ -14,63 +14,26 @@ export type TLExportType = 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
|
||||||
*
|
*
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function exportAs(
|
export async function exportAs(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
ids: TLShapeId[],
|
ids: TLShapeId[],
|
||||||
format: TLExportType = 'png',
|
format: TLExportType = 'png',
|
||||||
opts = {} as Partial<TLSvgOptions>
|
opts = {} as Partial<TLSvgOptions>
|
||||||
) {
|
) {
|
||||||
return editor
|
let name = `shapes at ${getTimestamp()}`
|
||||||
.getSvg(ids?.length ? ids : [...editor.getCurrentPageShapeIds()], opts)
|
if (ids.length === 1) {
|
||||||
.then((svg) => {
|
const first = editor.getShape(ids[0])!
|
||||||
if (!svg) {
|
if (editor.isShapeOfType<TLFrameShape>(first, 'frame')) {
|
||||||
throw new Error('Could not construct SVG.')
|
name = first.props.name ?? 'frame'
|
||||||
}
|
} else {
|
||||||
|
name = `${first.id.replace(/:/, '_')} at ${getTimestamp()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name += `.${format}`
|
||||||
|
|
||||||
let name = 'shapes' + getTimestamp()
|
const blob = await exportToBlob(editor, ids, format, opts)
|
||||||
|
const file = new File([blob], name, { type: blob.type })
|
||||||
if (ids.length === 1) {
|
downloadFile(file)
|
||||||
const first = editor.getShape(ids[0])!
|
|
||||||
if (editor.isShapeOfType<TLFrameShape>(first, 'frame')) {
|
|
||||||
name = first.props.name ?? 'frame'
|
|
||||||
} else {
|
|
||||||
name = first.id.replace(/:/, '_')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (format) {
|
|
||||||
case 'svg': {
|
|
||||||
getSvgAsDataUrl(svg).then((dataURL) => downloadDataURLAsFile(dataURL, `${name}.svg`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case 'webp':
|
|
||||||
case 'png': {
|
|
||||||
getSvgAsImage(svg, editor.environment.isSafari, {
|
|
||||||
type: format,
|
|
||||||
quality: 1,
|
|
||||||
scale: 2,
|
|
||||||
}).then((image) => {
|
|
||||||
if (!image) throw Error()
|
|
||||||
const dataURL = URL.createObjectURL(image)
|
|
||||||
downloadDataURLAsFile(dataURL, `${name}.${format}`)
|
|
||||||
URL.revokeObjectURL(dataURL)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'json': {
|
|
||||||
const data = editor.getContentFromCurrentPage(ids)
|
|
||||||
const blob = new Blob([JSON.stringify(data, null, 4)], { type: 'application/json' })
|
|
||||||
const dataURL = URL.createObjectURL(blob)
|
|
||||||
downloadDataURLAsFile(dataURL, `${name || 'shapes'}.json`)
|
|
||||||
URL.revokeObjectURL(dataURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Export type ${format} not supported.`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimestamp() {
|
function getTimestamp() {
|
||||||
|
@ -83,12 +46,14 @@ function getTimestamp() {
|
||||||
const minutes = String(now.getMinutes()).padStart(2, '0')
|
const minutes = String(now.getMinutes()).padStart(2, '0')
|
||||||
const seconds = String(now.getSeconds()).padStart(2, '0')
|
const seconds = String(now.getSeconds()).padStart(2, '0')
|
||||||
|
|
||||||
return ` at ${year}-${month}-${day} ${hours}.${minutes}.${seconds}`
|
return `${year}-${month}-${day} ${hours}.${minutes}.${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadDataURLAsFile(dataUrl: string, filename: string) {
|
function downloadFile(file: File) {
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = dataUrl
|
const url = URL.createObjectURL(file)
|
||||||
link.download = filename
|
link.href = url
|
||||||
|
link.download = file.name
|
||||||
link.click()
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|