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)
export function getSvgAsImage(svg: SVGElement, isSafari: boolean, options: {
type: 'jpeg' | 'png' | 'svg' | 'webp';
type: 'jpeg' | 'png' | 'webp';
quality: number;
scale: number;
}): Promise<Blob | null>;

View file

@ -7799,7 +7799,7 @@
},
{
"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",

View file

@ -1,5 +1,5 @@
import { Editor, TLShapeId, TLSvgOptions } from '@tldraw/editor'
import { getSvgAsImage } from './export'
import { Editor, TLShapeId, TLSvgOptions, exhaustiveSwitchError } from '@tldraw/editor'
import { exportToBlobPromise, exportToString } from './export'
/** @public */
export type TLCopyType = 'svg' | 'png' | 'jpeg' | 'json'
@ -19,121 +19,41 @@ export function copyAs(
ids: TLShapeId[],
format: TLCopyType = 'svg',
opts = {} as Partial<TLSvgOptions>
) {
// Note: it's important that this function itself isn't async - we need to create the relevant
// `ClipboardItem`s synchronously to make sure safari knows that the user _wants_ to copy
// See https://bugs.webkit.org/show_bug.cgi?id=222262
): Promise<void> {
// Note: it's important that this function itself isn't async and doesn't really use promises -
// we need to create the relevant `ClipboardItem`s and call window.navigator.clipboard.write
// synchronously to make sure safari knows that the user _wants_ to copy See
// https://bugs.webkit.org/show_bug.cgi?id=222262
return editor
.getSvg(ids?.length ? ids : [...editor.getCurrentPageShapeIds()], {
scale: 1,
background: editor.getInstanceState().exportBackground,
...opts,
})
.then((svg) => {
if (!svg) {
throw new Error('Could not construct SVG.')
}
if (!window.navigator.clipboard) return Promise.reject(new Error('Copy not supported'))
if (window.navigator.clipboard.write) {
const { blobPromise, mimeType } = exportToBlobPromise(editor, ids, format, opts)
switch (format) {
case 'svg': {
if (window.navigator.clipboard) {
if (window.navigator.clipboard.write) {
window.navigator.clipboard.write([
new ClipboardItem({
'text/plain': new Blob([getSvgAsString(svg)], { type: 'text/plain' }),
}),
])
} else {
fallbackWriteTextAsync(async () => getSvgAsString(svg))
}
}
break
}
return window.navigator.clipboard
.write([new ClipboardItem({ [mimeType]: blobPromise })])
.catch((err) => {
// Firefox will fail with the above if `dom.events.asyncClipboard.clipboardItem` is enabled.
// See <https://github.com/tldraw/tldraw/issues/1325>
console.error(err)
return blobPromise.then((blob) => {
return window.navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
})
})
}
case 'jpeg':
case 'png': {
// Note: This needs to use the promise based approach for safari/ios to not bail on a permissions error.
const blobPromise = getSvgAsImage(svg, editor.environment.isSafari, {
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')
}
})
switch (format) {
case 'json':
case 'svg':
return fallbackWriteTextAsync(async () => exportToString(editor, ids, format, opts))
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'
if (window.navigator.clipboard.write) {
window.navigator.clipboard
.write([
new ClipboardItem({
[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.`)
}
})
case 'jpeg':
case 'png':
throw new Error('Copy not supported')
default:
exhaustiveSwitchError(format)
}
}
async function fallbackWriteTextAsync(getText: () => Promise<string>) {
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
await navigator.clipboard?.writeText?.(await getText())
}

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'
/** @public */
@ -6,7 +13,7 @@ export async function getSvgAsImage(
svg: SVGElement,
isSafari: boolean,
options: {
type: 'svg' | 'png' | 'jpeg' | 'webp'
type: 'png' | 'jpeg' | 'webp'
quality: number
scale: number
}
@ -18,7 +25,8 @@ export async function getSvgAsImage(
let scaledWidth = width * 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()
if (width > canvasSizes.maxWidth) {
@ -62,7 +70,7 @@ export async function getSvgAsImage(
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight)
URL.revokeObjectURL(dataUrl)
URL.revokeObjectURL(svgUrl)
resolve(canvas)
}
@ -71,7 +79,7 @@ export async function getSvgAsImage(
resolve(null)
}
image.src = dataUrl
image.src = svgUrl
})
if (!canvas) return null
@ -97,10 +105,11 @@ export async function getSvgAsImage(
})
}
/** @public */
export async function getSvgAsDataUrl(svg: SVGElement) {
async function getSvgAsString(svg: SVGElement) {
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 imgs = Array.from(clone.querySelectorAll('image')) as SVGImageElement[]
@ -120,9 +129,96 @@ export async function getSvgAsDataUrl(svg: SVGElement) {
}
}
const svgStr = new XMLSerializer().serializeToString(clone)
// NOTE: `unescape` works everywhere although deprecated
// eslint-disable-next-line deprecation/deprecation
const base64SVG = window.btoa(unescape(encodeURIComponent(svgStr)))
return `data:image/svg+xml;base64,${base64SVG}`
const out = new XMLSerializer()
.serializeToString(clone)
.replaceAll('&#10; ', '')
.replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1')
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 { getSvgAsDataUrl, getSvgAsImage } from './export'
import { exportToBlob } from './export'
/** @public */
export type TLExportType = 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
@ -14,63 +14,26 @@ export type TLExportType = 'svg' | 'png' | 'jpeg' | 'webp' | 'json'
*
* @public
*/
export function exportAs(
export async function exportAs(
editor: Editor,
ids: TLShapeId[],
format: TLExportType = 'png',
opts = {} as Partial<TLSvgOptions>
) {
return editor
.getSvg(ids?.length ? ids : [...editor.getCurrentPageShapeIds()], opts)
.then((svg) => {
if (!svg) {
throw new Error('Could not construct SVG.')
}
let name = `shapes at ${getTimestamp()}`
if (ids.length === 1) {
const first = editor.getShape(ids[0])!
if (editor.isShapeOfType<TLFrameShape>(first, 'frame')) {
name = first.props.name ?? 'frame'
} else {
name = `${first.id.replace(/:/, '_')} at ${getTimestamp()}`
}
}
name += `.${format}`
let name = 'shapes' + getTimestamp()
if (ids.length === 1) {
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.`)
}
})
const blob = await exportToBlob(editor, ids, format, opts)
const file = new File([blob], name, { type: blob.type })
downloadFile(file)
}
function getTimestamp() {
@ -83,12 +46,14 @@ function getTimestamp() {
const minutes = String(now.getMinutes()).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')
link.href = dataUrl
link.download = filename
const url = URL.createObjectURL(file)
link.href = url
link.download = file.name
link.click()
URL.revokeObjectURL(url)
}