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)
|
||||
export function getSvgAsImage(svg: SVGElement, isSafari: boolean, options: {
|
||||
type: 'jpeg' | 'png' | 'svg' | 'webp';
|
||||
type: 'jpeg' | 'png' | 'webp';
|
||||
quality: number;
|
||||
scale: number;
|
||||
}): Promise<Blob | null>;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(' ', '')
|
||||
.replaceAll(/((\s|")[0-9]*\.[0-9]{2})([0-9]*)(\b|"|\))/g, '$1')
|
||||
|
||||
return out
|
||||
await navigator.clipboard?.writeText?.(await getText())
|
||||
}
|
||||
|
|
|
@ -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(' ', '')
|
||||
.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],
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|