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>
This commit is contained in:
alex 2024-01-09 14:50:10 +00:00 committed by GitHub
parent 6e9fe0c8be
commit 25328f98d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 164 additions and 183 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -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>;

View file

@ -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",

View file

@ -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('&#10; ', '')
.replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1')
return out
} }

View file

@ -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('&#10; ', '')
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],
}
} }

View file

@ -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)
} }