Tldraw/apps/dotcom/src/utils/sharing.ts

253 wiersze
7.3 KiB
TypeScript

import {
CreateRoomRequestBody,
CreateSnapshotRequestBody,
CreateSnapshotResponseBody,
Snapshot,
} from '@tldraw/dotcom-shared'
import { useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
AssetRecordType,
Editor,
TLAsset,
TLAssetId,
TLRecord,
TLShape,
TLShapeId,
TLUiEventHandler,
TLUiOverrides,
TLUiToastsContextType,
TLUiTranslationKey,
isShape,
} from 'tldraw'
import { useMultiplayerAssets } from '../hooks/useMultiplayerAssets'
import { getViewportUrlQuery } from '../hooks/useUrlState'
import { cloneAssetForShare } from './cloneAssetForShare'
import { ASSET_UPLOADER_URL } from './config'
import { getParentOrigin, isInIframe } from './iFrame'
import { shouldLeaveSharedProject } from './shouldLeaveSharedProject'
import { trackAnalyticsEvent } from './trackAnalyticsEvent'
import { UI_OVERRIDE_TODO_EVENT, useHandleUiEvents } from './useHandleUiEvent'
export const SHARE_PROJECT_ACTION = 'share-project' as const
export const SHARE_SNAPSHOT_ACTION = 'share-snapshot' as const
export const LEAVE_SHARED_PROJECT_ACTION = 'leave-shared-project' as const
export const FORK_PROJECT_ACTION = 'fork-project' as const
const CREATE_SNAPSHOT_ENDPOINT = `/api/snapshots`
const SNAPSHOT_UPLOAD_URL = `/api/new-room`
async function getSnapshotLink(
source: string,
editor: Editor,
handleUiEvent: TLUiEventHandler,
addToast: TLUiToastsContextType['addToast'],
msg: (id: TLUiTranslationKey) => string,
uploadFileToAsset: (file: File) => Promise<TLAsset>,
parentSlug: string | undefined
) {
handleUiEvent('share-snapshot' as UI_OVERRIDE_TODO_EVENT, { source } as UI_OVERRIDE_TODO_EVENT)
const data = await getRoomData(editor, addToast, msg, uploadFileToAsset)
if (!data) return ''
const res = await fetch(CREATE_SNAPSHOT_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
snapshot: data,
schema: editor.store.schema.serialize(),
parent_slug: parentSlug,
} satisfies CreateSnapshotRequestBody),
})
const response = (await res.json()) as CreateSnapshotResponseBody
if (!res.ok || response.error) {
console.error(await res.text())
return ''
}
const paramsToUse = getViewportUrlQuery(editor)
const params = paramsToUse ? `?${new URLSearchParams(paramsToUse).toString()}` : ''
return new Blob([`${window.location.origin}/s/${response.roomId}${params}`], {
type: 'text/plain',
})
}
export async function getNewRoomResponse(snapshot: Snapshot) {
return await fetch(SNAPSHOT_UPLOAD_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
origin: getParentOrigin(),
snapshot,
} satisfies CreateRoomRequestBody),
})
}
export function useSharing(): TLUiOverrides {
const navigate = useNavigate()
const id = useSearchParams()[0].get('id') ?? undefined
const uploadFileToAsset = useMultiplayerAssets(ASSET_UPLOADER_URL)
const handleUiEvent = useHandleUiEvents()
const runningInIFrame = isInIframe()
return useMemo(
(): TLUiOverrides => ({
actions(editor, actions, { addToast, msg, addDialog }) {
actions[LEAVE_SHARED_PROJECT_ACTION] = {
id: LEAVE_SHARED_PROJECT_ACTION,
label: 'action.leave-shared-project',
readonlyOk: true,
onSelect: async () => {
const shouldLeave = await shouldLeaveSharedProject(addDialog)
if (!shouldLeave) return
handleUiEvent('leave-shared-project', {})
navigate('/')
},
}
actions[SHARE_PROJECT_ACTION] = {
id: SHARE_PROJECT_ACTION,
label: 'action.share-project',
readonlyOk: true,
onSelect: async (source) => {
try {
handleUiEvent('share-project', { source })
const data = await getRoomData(editor, addToast, msg, uploadFileToAsset)
if (!data) return
const res = await getNewRoomResponse({
schema: editor.store.schema.serialize(),
snapshot: data,
})
const response = (await res.json()) as { error: boolean; slug?: string }
if (!res.ok || response.error) {
console.error(await res.text())
throw new Error('Failed to upload snapshot')
}
const query = getViewportUrlQuery(editor)
const origin = window.location.origin
const pathname = `/r/${response.slug}?${new URLSearchParams(query ?? {}).toString()}`
if (runningInIFrame) {
window.open(`${origin}${pathname}`)
} else {
navigate(pathname)
}
} catch (error) {
console.error(error)
addToast({
title: 'Error',
description: msg('share-menu.upload-failed'),
severity: 'error',
})
}
},
}
actions[SHARE_SNAPSHOT_ACTION] = {
id: SHARE_SNAPSHOT_ACTION,
label: 'share-menu.create-snapshot-link',
readonlyOk: true,
onSelect: async (source) => {
const result = getSnapshotLink(
source,
editor,
handleUiEvent,
addToast,
msg,
uploadFileToAsset,
id
)
if (navigator?.clipboard?.write) {
await navigator.clipboard.write([
new ClipboardItem({
'text/plain': result,
}),
])
} else if (navigator?.clipboard?.writeText) {
const link = await result
if (link === '') return
navigator.clipboard.writeText(await link.text())
}
},
}
actions[FORK_PROJECT_ACTION] = {
...actions[SHARE_PROJECT_ACTION],
id: FORK_PROJECT_ACTION,
label: runningInIFrame ? 'action.fork-project-on-tldraw' : 'action.fork-project',
}
return actions
},
}),
[handleUiEvent, navigate, uploadFileToAsset, id, runningInIFrame]
)
}
async function getRoomData(
editor: Editor,
addToast: TLUiToastsContextType['addToast'],
msg: (id: TLUiTranslationKey) => string,
uploadFileToAsset: (file: File) => Promise<TLAsset>
) {
const rawData = editor.store.serialize()
// rawData contains a cache of previously added assets,
// which we don't want included in the shared document.
// So let's strip it out.
// our final object that holds the data that we'll persist to a stash
const data: Record<string, TLRecord> = {}
// let's get all the assets/shapes in data
const shapes = new Map<TLShapeId, TLShape>()
const assets = new Map<TLAssetId, TLAsset>()
for (const record of Object.values(rawData)) {
if (AssetRecordType.isInstance(record)) {
// collect assets separately, don't add them to the proper doc yet
assets.set(record.id, record)
continue
}
data[record.id] = record
if (isShape(record)) {
shapes.set(record.id, record)
}
}
// now add only those assets that are referenced in shapes
for (const shape of shapes.values()) {
if ('assetId' in shape.props) {
const asset = assets.get(shape.props.assetId as TLAssetId)
// if we can't find the asset it either means
// somethings gone wrong or we've already
// processed it
if (!asset) continue
data[asset.id] = await cloneAssetForShare(asset, uploadFileToAsset)
// remove the asset after processing so we don't clone it multiple times
assets.delete(asset.id)
}
}
const size = new Blob([JSON.stringify(data)]).size
if (size > 3999999) {
addToast({
title: 'Too big!',
description: msg('share-menu.project-too-large'),
severity: 'warning',
})
trackAnalyticsEvent('shared-fail-too-big', {
size: size.toString(),
})
return null
}
return data
}