diff --git a/src/app/features/import-export/lib/export.ts b/src/app/features/import-export/lib/export.ts
index b68f66da..f4b7a092 100644
--- a/src/app/features/import-export/lib/export.ts
+++ b/src/app/features/import-export/lib/export.ts
@@ -1,4 +1,5 @@
import JSZip from "jszip"
+import { assetExtensionFromMimeType } from "@/app/features/assets"
import type { ExportComment } from "@/app/features/comments"
export {
@@ -152,18 +153,7 @@ function sanitizeFilename(name: string): string {
}
function getExtensionFromBlob(blob: Blob): string {
- let mimeToExt: Record
= {
- "image/png": ".png",
- "image/jpeg": ".jpg",
- "image/gif": ".gif",
- "image/webp": ".webp",
- "image/svg+xml": ".svg",
- "image/bmp": ".bmp",
- "video/mp4": ".mp4",
- "video/webm": ".webm",
- "video/quicktime": ".mov",
- }
- return mimeToExt[blob.type] || ".png"
+ return assetExtensionFromMimeType(blob.type) ?? ".png"
}
// Structure:
diff --git a/src/app/features/import-export/lib/import.ts b/src/app/features/import-export/lib/import.ts
index e0563b1e..2f97edc8 100644
--- a/src/app/features/import-export/lib/import.ts
+++ b/src/app/features/import-export/lib/import.ts
@@ -1,4 +1,8 @@
import JSZip from "jszip"
+import {
+ assetMimeTypeFromFileName,
+ isAssetFileName,
+} from "@/app/features/assets"
import type { ExportComment } from "./export"
export {
@@ -133,7 +137,7 @@ async function importFolderFiles(
mdFiles.push({ path, file })
} else if (isCommentSidecar(fileName)) {
sidecarFiles.push({ key: path, file })
- } else if (isMediaFile(fileName)) {
+ } else if (isAssetFileName(fileName)) {
assetFiles.push({ path, file })
}
}
@@ -277,7 +281,7 @@ async function importZipFile(file: File): Promise {
mdFiles.push({ path: relativePath, name: fileName })
} else if (isCommentSidecar(fileName)) {
sidecarFiles.push({ key: relativePath, file: zipEntry })
- } else if (isMediaFile(fileName)) {
+ } else if (isAssetFileName(fileName)) {
assetFiles.push({ path: relativePath, file: zipEntry })
}
})
@@ -319,7 +323,7 @@ async function importZipFile(file: File): Promise {
let blob = await assetEntry.file.async("blob")
let fileName = assetEntry.path.split("/").pop() || "image"
let assetFile = new File([blob], fileName, {
- type: getMimeType(fileName),
+ type: assetMimeTypeFromFileName(fileName),
})
assets.push({
@@ -337,7 +341,7 @@ async function importZipFile(file: File): Promise {
let blob = await assetFile.file.async("blob")
let fileName = assetFile.path.split("/").pop() || "image"
let file = new File([blob], fileName, {
- type: getMimeType(fileName),
+ type: assetMimeTypeFromFileName(fileName),
})
assets.push({
name: fileName.replace(/\.[^.]+$/, ""),
@@ -450,37 +454,6 @@ function getCommentSidecarPath(markdownPath: string): string {
return markdownPath.replace(/\.(md|markdown|txt)$/i, ".comments.json")
}
-function isImageFile(filename: string): boolean {
- let ext = filename.toLowerCase().split(".").pop()
- return ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"].includes(ext || "")
-}
-
-function isVideoFile(filename: string): boolean {
- let ext = filename.toLowerCase().split(".").pop()
- return ["mp4", "webm", "mov"].includes(ext || "")
-}
-
-function isMediaFile(filename: string): boolean {
- return isImageFile(filename) || isVideoFile(filename)
-}
-
-function getMimeType(filename: string): string {
- let ext = filename.toLowerCase().split(".").pop()
- let mimeTypes: Record = {
- png: "image/png",
- jpg: "image/jpeg",
- jpeg: "image/jpeg",
- gif: "image/gif",
- webp: "image/webp",
- svg: "image/svg+xml",
- bmp: "image/bmp",
- mp4: "video/mp4",
- webm: "video/webm",
- mov: "video/quicktime",
- }
- return mimeTypes[ext || ""] || "application/octet-stream"
-}
-
function resolveWikilinkPath(
linkPath: string,
currentDocPath: string | null,
diff --git a/src/app/features/import-export/widgets/sidebar-import-export.tsx b/src/app/features/import-export/widgets/sidebar-import-export.tsx
index 576c7ff8..6218cfcb 100644
--- a/src/app/features/import-export/widgets/sidebar-import-export.tsx
+++ b/src/app/features/import-export/widgets/sidebar-import-export.tsx
@@ -1,8 +1,14 @@
import { useRef, useState } from "react"
import { Group, co } from "jazz-tools"
-import { createImage } from "jazz-tools/media"
-import { Document, Asset, ImageAsset, VideoAsset } from "@/schema"
-import { compressVideo, canEncodeVideo } from "@/app/features/assets"
+import { Document, Asset } from "@/schema"
+import {
+ assetContentResolve,
+ classifyAssetFile,
+ compressVideo,
+ canEncodeVideo,
+ createAssetFromFile,
+ serializeAsset,
+} from "@/app/features/assets"
import { getPath } from "@/app/features/editor"
import { Button } from "@/app/components/ui/button"
import {
@@ -229,12 +235,12 @@ async function handleImportFiles(
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
let importedAsset = importedAssets[assetIndex]
- let isVideo = importedAsset.file.type.startsWith("video/")
- let asset: co.loaded
-
- if (isVideo) {
+ let kind = classifyAssetFile(importedAsset.file)
+ if (!kind)
+ throw new Error(`Unsupported asset: ${importedAsset.file.name}`)
+ let blob: Blob = importedAsset.file
+ if (kind === "video") {
// Compress video if supported, otherwise use original
- let videoBlob: Blob = importedAsset.file
if (await canEncodeVideo()) {
onProgress?.({
phase: "compressing",
@@ -246,7 +252,7 @@ async function handleImportFiles(
compressionProgress: 0,
})
try {
- videoBlob = await compressVideo(importedAsset.file, {
+ blob = await compressVideo(importedAsset.file, {
onProgress: p =>
onProgress?.({
phase: "compressing",
@@ -264,29 +270,16 @@ async function handleImportFiles(
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
}
}
- let video = await co.fileStream().createFromBlob(videoBlob, {
- owner: docGroup,
- })
- asset = VideoAsset.create(
- {
- type: "video",
- name: importedAsset.name,
- video,
- mimeType: "video/mp4",
- createdAt: now,
- },
- docGroup,
- )
- } else {
- let image = await createImage(importedAsset.file, {
- owner: docGroup,
- maxSize: 2048,
- })
- asset = ImageAsset.create(
- { type: "image", name: importedAsset.name, image, createdAt: now },
- docGroup,
- )
}
+ let asset = await createAssetFromFile(
+ {
+ blob,
+ fileName: importedAsset.file.name,
+ name: importedAsset.name,
+ createdAt: now,
+ },
+ docGroup,
+ )
docAssets.push(asset)
if (importedAsset.refName) {
@@ -408,7 +401,11 @@ async function loadDocumentAssets(
doc: co.loaded,
): Promise {
let loaded = await doc.$jazz.ensureLoaded({
- resolve: { assets: { $each: { image: true, video: true } } },
+ resolve: {
+ assets: {
+ $each: assetContentResolve,
+ },
+ },
})
let docAssets: ExportAsset[] = []
@@ -416,19 +413,8 @@ async function loadDocumentAssets(
for (let asset of Array.from(loaded.assets)) {
if (!asset?.$isLoaded) continue
- if (asset.type === "image" && asset.image?.$isLoaded) {
- let original = asset.image.original
- if (!original?.$isLoaded) continue
- let blob = original.toBlob()
- if (blob) {
- docAssets.push({ id: asset.$jazz.id, name: asset.name, blob })
- }
- } else if (asset.type === "video" && asset.video?.$isLoaded) {
- let blob = asset.video.toBlob()
- if (blob) {
- docAssets.push({ id: asset.$jazz.id, name: asset.name, blob })
- }
- }
+ let blob = await serializeAsset(asset)
+ if (blob) docAssets.push({ id: asset.$jazz.id, name: asset.name, blob })
}
}
diff --git a/src/app/features/presentation/screens/slideshow-screen.tsx b/src/app/features/presentation/screens/slideshow-screen.tsx
index 9f6079cf..0ef18f0d 100644
--- a/src/app/features/presentation/screens/slideshow-screen.tsx
+++ b/src/app/features/presentation/screens/slideshow-screen.tsx
@@ -16,13 +16,16 @@ import { canEdit } from "@/app/features/sharing"
import { Slideshow, type Slide } from "../widgets/slideshow"
import { parsePresentation, type PresentationItem } from "../lib/presentation"
import { useScreenWakeLock } from "../lib/screen-wake-lock"
+import { assetPreviewResolve } from "@/app/features/assets"
export { SlideshowScreen, resolve, loadWikilinkCache }
export type { LoaderData }
let resolve = {
content: true,
- assets: { $each: { image: true, video: true } },
+ assets: {
+ $each: assetPreviewResolve,
+ },
} as const satisfies ResolveQuery
type LoadedDoc = co.loaded
diff --git a/src/app/features/presentation/widgets/slideshow.tsx b/src/app/features/presentation/widgets/slideshow.tsx
index 608e6abf..1fee82dc 100644
--- a/src/app/features/presentation/widgets/slideshow.tsx
+++ b/src/app/features/presentation/widgets/slideshow.tsx
@@ -82,10 +82,14 @@ let ContentContext = createContext("")
type Asset = {
$jazz: { id: string }
$isLoaded?: boolean
- type?: "image" | "video"
+ type?: "image" | "video" | "tldraw"
image?: { $jazz: { id: string } }
video?: { $isLoaded?: boolean; toBlob?: () => Blob | undefined }
muteAudio?: boolean
+ revision?: {
+ lightPreview?: { $jazz: { id: string } }
+ darkPreview?: { $jazz: { id: string } }
+ }
}
let AssetContext = createContext(undefined)
@@ -608,6 +612,7 @@ function ScaledSlideContainer({
fontSet?.removeEventListener?.("loadingerror", handleFontsDone)
}
}, [
+ blocks,
blocksKey,
isPortrait,
baseSize,
@@ -878,6 +883,7 @@ function SlideContentItem({ item }: { item: SlideContent }) {
function SlideImage({ src, alt }: { src: string; alt: string }) {
let assets = useContext(AssetContext)
+ let colorScheme = useResolvedTheme()
let assetMatch = src.match(/^asset:(.+)$/)
if (assetMatch) {
@@ -895,6 +901,23 @@ function SlideImage({ src, alt }: { src: string; alt: string }) {
)
}
+ if (asset?.$isLoaded && asset.type === "tldraw") {
+ let preview =
+ colorScheme === "dark"
+ ? asset.revision?.darkPreview
+ : asset.revision?.lightPreview
+ if (preview) {
+ return (
+
+ )
+ }
+ }
+
if (asset?.$isLoaded && asset.video) {
return
}
diff --git a/src/app/features/time-machine/lib/time-machine.test.ts b/src/app/features/time-machine/lib/time-machine.test.ts
index 88145197..5eceabfe 100644
--- a/src/app/features/time-machine/lib/time-machine.test.ts
+++ b/src/app/features/time-machine/lib/time-machine.test.ts
@@ -1,14 +1,21 @@
import { beforeEach, describe, expect, test } from "vitest"
import { createJazzTestAccount, setupJazzTestSync } from "jazz-tools/testing"
-import { Document, UserAccount } from "@/schema"
+import { co } from "jazz-tools"
+import {
+ Document,
+ Asset,
+ TldrawAsset,
+ TldrawRevision,
+ UserAccount,
+} from "@/schema"
import { createPersonalDocument } from "@/app/features/documents/lib/documents"
import {
getEditHistory,
getContentAtEdit,
accountIdFromSessionId,
formatEditDate,
+ restoreAssetsAtTime,
} from "./time-machine"
-import type { co } from "jazz-tools"
describe("Time Machine - Edit History", () => {
let adminAccount: co.loaded
@@ -184,6 +191,73 @@ describe("Time Machine - Edit History", () => {
expect(middleContent.length).toBeGreaterThan(0)
})
+ test("restores the tldraw revision active at the selected time", async () => {
+ let doc = await createPersonalDocument(adminAccount, "Whiteboard")
+ let firstRevision = await createTldrawTestRevision(adminAccount, "first")
+ let asset = TldrawAsset.create(
+ {
+ version: 1,
+ type: "tldraw",
+ name: "Diagram",
+ revision: firstRevision,
+ createdAt: new Date(),
+ },
+ adminAccount,
+ )
+ doc.$jazz.set("assets", co.list(Asset).create([], adminAccount))
+ doc.assets!.$jazz.push(asset)
+
+ await new Promise(resolve => setTimeout(resolve, 20))
+ let firstRevisionTime = Date.now()
+ await new Promise(resolve => setTimeout(resolve, 20))
+ let secondRevision = await createTldrawTestRevision(adminAccount, "second")
+ asset.$jazz.set("revision", secondRevision)
+
+ let loaded = await Document.load(doc.$jazz.id, {
+ resolve: { content: true, assets: true },
+ })
+ if (!loaded.$isLoaded) throw new Error("Doc not loaded")
+
+ await restoreAssetsAtTime(loaded, firstRevisionTime)
+
+ let restored = loaded.assets?.[0]
+ expect(restored?.$isLoaded && restored.type).toBe("tldraw")
+ if (!restored?.$isLoaded || restored.type !== "tldraw") return
+ expect(restored.revision?.$jazz.id).toBe(firstRevision.$jazz.id)
+ })
+
+ test("does not partially restore when a historical asset is unavailable", async () => {
+ let doc = await createPersonalDocument(adminAccount, "Whiteboard")
+ let revision = await createTldrawTestRevision(adminAccount, "deleted")
+ let asset = TldrawAsset.create(
+ {
+ version: 1,
+ type: "tldraw",
+ name: "Deleted diagram",
+ revision,
+ createdAt: new Date(),
+ },
+ adminAccount,
+ )
+ doc.$jazz.set("assets", co.list(Asset).create([asset], adminAccount))
+
+ await new Promise(resolve => setTimeout(resolve, 20))
+ let historicalTimestamp = Date.now()
+ await new Promise(resolve => setTimeout(resolve, 20))
+ doc.assets!.$jazz.splice(0, 1)
+ asset.$jazz.raw.core.deleteCoValue()
+
+ let loaded = await Document.load(doc.$jazz.id, {
+ resolve: { content: true, assets: true },
+ })
+ if (!loaded.$isLoaded) throw new Error("Doc not loaded")
+
+ await expect(
+ restoreAssetsAtTime(loaded, historicalTimestamp),
+ ).rejects.toThrow("Historical asset is unavailable")
+ expect(loaded.assets).toHaveLength(0)
+ })
+
test("getEditHistory maintains O(n) complexity", async () => {
let doc = await createPersonalDocument(adminAccount, "Initial")
@@ -217,6 +291,53 @@ describe("Time Machine - Edit History", () => {
})
})
+async function createTldrawTestRevision(
+ owner: co.loaded,
+ content: string,
+) {
+ let snapshot = await co
+ .fileStream()
+ .createFromBlob(new ReadableBlob(content, "application/vnd.tldraw+json"), {
+ owner,
+ })
+ let original = await co.fileStream().createFromBlob(new ReadableBlob(""), {
+ owner,
+ })
+ let preview = co.image().create(
+ {
+ original,
+ originalSize: [1, 1],
+ progressive: false,
+ },
+ owner,
+ )
+ return TldrawRevision.create(
+ {
+ snapshot,
+ lightPreview: preview,
+ darkPreview: preview,
+ createdAt: new Date(),
+ },
+ owner,
+ )
+}
+
+class ReadableBlob extends Blob {
+ readonly content: string
+
+ constructor(content: string, type = "application/octet-stream") {
+ super([content], { type })
+ this.content = content
+ }
+
+ async arrayBuffer() {
+ let bytes = new TextEncoder().encode(this.content)
+ let buffer = new ArrayBuffer(bytes.byteLength)
+ new Uint8Array(buffer).set(bytes)
+ return buffer
+ }
+}
+
describe("Time Machine - Offline Support", () => {
let adminAccount: co.loaded
diff --git a/src/app/features/time-machine/lib/time-machine.ts b/src/app/features/time-machine/lib/time-machine.ts
index 1869e4ff..0879c65e 100644
--- a/src/app/features/time-machine/lib/time-machine.ts
+++ b/src/app/features/time-machine/lib/time-machine.ts
@@ -1,9 +1,13 @@
import type { co } from "jazz-tools"
import type { Document, UserAccount } from "@/schema"
+import { Asset, TldrawRevision } from "@/schema"
export {
getEditHistory,
getContentAtEdit,
+ getEditTimestamp,
+ getHistoricalAssetIds,
+ restoreAssetsAtTime,
formatEditDate,
getAuthorName,
accountIdFromSessionId,
@@ -67,6 +71,9 @@ function getEditHistory(doc: LoadedDocument): EditHistoryItem[] {
if (doc.assets?.$isLoaded) {
collectTransactions(doc.assets.$jazz.raw.core)
+ for (let asset of doc.assets.values()) {
+ if (asset?.$isLoaded) collectTransactions(asset.$jazz.raw.core)
+ }
}
if (doc.comments?.$isLoaded) {
@@ -120,6 +127,84 @@ function getContentAtEdit(doc: LoadedDocument, editIndex: number): string {
return doc.content.$jazz.raw.atTime(edit.madeAt.getTime()).toString()
}
+function getEditTimestamp(doc: LoadedDocument, editIndex: number): number {
+ let edits = getEditHistory(doc)
+ return edits[editIndex]?.madeAt.getTime() ?? Date.now()
+}
+
+function getHistoricalAssetIds(
+ doc: LoadedDocument,
+ timestamp: number,
+): string[] {
+ if (!doc.assets?.$isLoaded) return []
+ let items = doc.assets.$jazz.raw.atTime(timestamp).toJSON()
+ return items.filter(item => typeof item === "string")
+}
+
+async function restoreAssetsAtTime(doc: LoadedDocument, timestamp: number) {
+ if (!doc.assets?.$isLoaded) return
+
+ let assetIds = getHistoricalAssetIds(doc, timestamp)
+ let assets = await Promise.all(
+ assetIds.map(assetId =>
+ Asset.load(assetId, {
+ resolve: {
+ image: true,
+ video: true,
+ revision: true,
+ },
+ }),
+ ),
+ )
+ let loadedAssets = assets.map(asset => {
+ if (!asset?.$isLoaded) throw new Error("Historical asset is unavailable")
+ return asset
+ })
+ let restorations = await Promise.all(
+ loadedAssets.map(async asset => {
+ let historical = asset.$jazz.raw.atTime(timestamp)
+ let historicalName = historical.get("name")
+ let name =
+ typeof historicalName === "string" ? historicalName : asset.name
+ if (asset.type !== "tldraw") {
+ return { asset, name, revision: undefined }
+ }
+ let revisionId = historical.get("revision")
+ if (typeof revisionId !== "string") {
+ throw new Error("Historical whiteboard revision is unavailable")
+ }
+ let revision = await TldrawRevision.load(revisionId, {
+ resolve: {
+ snapshot: true,
+ lightPreview: true,
+ darkPreview: true,
+ },
+ })
+ if (!revision?.$isLoaded) {
+ throw new Error("Historical whiteboard revision is unavailable")
+ }
+ return { asset, name, revision }
+ }),
+ )
+
+ for (let { asset, name, revision } of restorations) {
+ if (asset.type === "image" && name !== asset.name) {
+ asset.$jazz.set("name", name)
+ }
+ if (asset.type === "video" && name !== asset.name) {
+ asset.$jazz.set("name", name)
+ }
+ if (asset.type === "tldraw" && name !== asset.name) {
+ asset.$jazz.set("name", name)
+ }
+ if (asset.type === "tldraw" && revision) {
+ asset.$jazz.set("revision", revision)
+ }
+ }
+
+ doc.assets.$jazz.splice(0, doc.assets.length, ...loadedAssets)
+}
+
function formatEditDate(date: Date): string {
return date.toLocaleString(undefined, {
month: "short",
@@ -184,6 +269,10 @@ function getTransactionCount(doc: LoadedDocument) {
if (doc.assets?.$isLoaded) {
count += doc.assets.$jazz.raw.core.getValidSortedTransactions().length
+ for (let asset of doc.assets.values()) {
+ if (!asset?.$isLoaded) continue
+ count += asset.$jazz.raw.core.getValidSortedTransactions().length
+ }
}
if (!doc.comments?.$isLoaded) return count
diff --git a/src/app/features/time-machine/screens/time-machine-screen.tsx b/src/app/features/time-machine/screens/time-machine-screen.tsx
index 52df2362..964adeb4 100644
--- a/src/app/features/time-machine/screens/time-machine-screen.tsx
+++ b/src/app/features/time-machine/screens/time-machine-screen.tsx
@@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from "react"
import { useNavigate } from "@tanstack/react-router"
import { co, Group, type ResolveQuery } from "jazz-tools"
-import { createImage } from "jazz-tools/media"
import { useCoState, useAccount } from "jazz-tools/react"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"
import {
@@ -12,7 +11,7 @@ import {
ChevronDown,
} from "lucide-react"
import { toast } from "sonner"
-import { Document, UserAccount, Asset, ImageAsset, Space } from "@/schema"
+import { Document, UserAccount, Asset, TldrawRevision, Space } from "@/schema"
import { MarkdownEditor, useMarkdownEditorRef } from "@/app/features/editor"
import { useEditorSettings } from "@/app/features/editor"
import {
@@ -47,6 +46,9 @@ import {
import {
getEditHistory,
getContentAtEdit,
+ getEditTimestamp,
+ restoreAssetsAtTime,
+ getHistoricalAssetIds,
getAuthorName,
formatEditDate,
groupEditsByDay,
@@ -67,6 +69,12 @@ import {
import { Kbd } from "@/app/components/ui/kbd"
import { cn } from "@/app/lib/cn"
import { useIntl } from "@/shared/intl/setup"
+import { useResolvedTheme } from "@/app/components/appearance"
+import {
+ assetContentResolve,
+ assetPreviewResolve,
+ copyAsset,
+} from "@/app/features/assets"
export { TimeMachineScreen, resolve, settingsResolve }
export type { ViewMode }
@@ -75,7 +83,9 @@ type ViewMode = "days" | "edits"
let resolve = {
content: true,
- assets: { $each: { image: true } },
+ assets: {
+ $each: assetContentResolve,
+ },
comments: { $each: { replies: true } },
} as const satisfies ResolveQuery
@@ -165,6 +175,7 @@ function TimeMachineContent({
let navigate = useNavigate()
let editor = useMarkdownEditorRef()
let restoreDialog = useConfirmDialog()
+ let colorScheme = useResolvedTheme()
let me = useAccount(UserAccount, { resolve: meResolve })
@@ -189,6 +200,12 @@ function TimeMachineContent({
let currentEdit = editHistory[currentEditIndex]
let timeMachineContent = getContentAtEdit(doc, currentEditIndex)
+ let historicalTimestamp = getEditTimestamp(doc, currentEditIndex)
+ let historicalAssets = useHistoricalEditorAssets(
+ doc,
+ historicalTimestamp,
+ colorScheme,
+ )
let viewMode: ViewMode = initialMode ?? "days"
let selectedDayIndex: number | null = null
@@ -348,7 +365,7 @@ function TimeMachineContent({
onCursorChange={() => {}}
placeholder=""
readOnly={true}
- assets={[]}
+ assets={historicalAssets}
documents={[]}
extensions={[]}
/>
@@ -370,6 +387,7 @@ function TimeMachineContent({
historicalContent: timeMachineContent,
originalTitle: docTitle,
editDate: currentEdit?.madeAt ?? doc.createdAt,
+ historicalTimestamp,
me,
navigate,
})}
@@ -387,8 +405,10 @@ function TimeMachineContent({
onConfirm={makeTimeMachineRestore({
doc,
historicalContent: timeMachineContent,
+ historicalTimestamp,
navigate,
docId,
+ restoreFailedMessage: t("timeMachine.restoreFailed"),
})}
/>
Blob | undefined }
+ muteAudio?: boolean
+}
+
+function useHistoricalEditorAssets(
+ doc: LoadedDocument,
+ timestamp: number,
+ colorScheme: "light" | "dark",
+) {
+ let [assets, setAssets] = useState([])
+
+ useEffect(() => {
+ let cancelled = false
+ void loadHistoricalEditorAssets(doc, timestamp, colorScheme).then(
+ result => {
+ if (!cancelled) setAssets(result)
+ },
+ )
+ return () => {
+ cancelled = true
+ }
+ }, [doc, timestamp, colorScheme])
+
+ return assets
+}
+
+async function loadHistoricalEditorAssets(
+ doc: LoadedDocument,
+ timestamp: number,
+ colorScheme: "light" | "dark",
+): Promise {
+ let assetIds = getHistoricalAssetIds(doc, timestamp)
+ let loaded = await Promise.all(
+ assetIds.map(assetId =>
+ Asset.load(assetId, {
+ resolve: assetPreviewResolve,
+ }),
+ ),
+ )
+ let result: HistoricalEditorAsset[] = []
+
+ for (let asset of loaded) {
+ if (!asset?.$isLoaded) continue
+ let historical = asset.$jazz.raw.atTime(timestamp)
+ let historicalName = historical.get("name")
+ let name = typeof historicalName === "string" ? historicalName : asset.name
+
+ if (asset.type === "image") {
+ let imageId = historical.get("image")
+ result.push({
+ id: asset.$jazz.id,
+ name,
+ type: "image",
+ imageId: typeof imageId === "string" ? imageId : undefined,
+ })
+ continue
+ }
+ if (asset.type === "video") {
+ result.push({
+ id: asset.$jazz.id,
+ name,
+ type: "video",
+ video: asset.video?.$isLoaded ? asset.video : undefined,
+ muteAudio: asset.muteAudio,
+ })
+ continue
+ }
+
+ let revisionId = historical.get("revision")
+ if (typeof revisionId !== "string") continue
+ let revision = await TldrawRevision.load(revisionId, {
+ resolve: { lightPreview: true, darkPreview: true },
+ })
+ if (!revision?.$isLoaded) continue
+ let preview =
+ colorScheme === "dark" ? revision.darkPreview : revision.lightPreview
+ result.push({
+ id: asset.$jazz.id,
+ name,
+ type: "tldraw",
+ imageId: preview?.$isLoaded ? preview.$jazz.id : undefined,
+ })
+ }
+
+ return result
+}
+
type TimeMachineCopyParams = {
doc: LoadedDocument
historicalContent: string
originalTitle: string
editDate: Date
+ historicalTimestamp: number
me: LoadedMe
navigate: ReturnType
}
@@ -415,8 +528,15 @@ type LoadedSpace = co.loaded
function makeTimeMachineCreateCopy(params: TimeMachineCopyParams) {
return async function handleTimeMachineCreateCopy() {
- let { doc, historicalContent, originalTitle, editDate, me, navigate } =
- params
+ let {
+ doc,
+ historicalContent,
+ originalTitle,
+ editDate,
+ historicalTimestamp,
+ me,
+ navigate,
+ } = params
if (!me.$isLoaded || !me.root?.documents?.$isLoaded) return
let owner: Group
@@ -442,38 +562,33 @@ function makeTimeMachineCreateCopy(params: TimeMachineCopyParams) {
let assetIdMap = new Map()
let newAssets = co.list(Asset).create([], owner)
- let assets = doc.assets ?? []
-
- for (let asset of [...assets]) {
- if (
- !asset?.$isLoaded ||
- asset.type !== "image" ||
- !asset.image?.$isLoaded
- )
- continue
-
- let original = asset.image.original
- if (!original?.$isLoaded) continue
+ let assetIds = getHistoricalAssetIds(doc, historicalTimestamp)
+ let assets = await Promise.all(
+ assetIds.map(assetId =>
+ Asset.load(assetId, {
+ resolve: assetContentResolve,
+ }),
+ ),
+ )
- let blob = original.toBlob()
- if (!blob) continue
+ for (let asset of assets) {
+ if (!asset?.$isLoaded) continue
+ let historical = asset.$jazz.raw.atTime(historicalTimestamp)
+ let historicalName = historical.get("name")
+ let name =
+ typeof historicalName === "string" ? historicalName : asset.name
try {
- let newImage = await createImage(blob, {
- owner,
- maxSize: 2048,
+ let revision =
+ asset.type === "tldraw"
+ ? await loadHistoricalTldrawRevision(asset, historicalTimestamp)
+ : undefined
+ if (asset.type === "tldraw" && !revision) continue
+ let newAsset = await copyAsset(asset, owner, {
+ name,
+ tldrawRevision: revision,
})
- let newAsset = ImageAsset.create(
- {
- type: "image",
- name: asset.name,
- image: newImage,
- createdAt: new Date(),
- },
- owner,
- )
-
newAssets.$jazz.push(newAsset)
assetIdMap.set(asset.$jazz.id, newAsset.$jazz.id)
} catch (err) {
@@ -535,22 +650,48 @@ function makeTimeMachineCreateCopy(params: TimeMachineCopyParams) {
}
}
+async function loadHistoricalTldrawRevision(
+ asset: co.loaded,
+ timestamp: number,
+) {
+ let revisionId = asset.$jazz.raw.atTime(timestamp).get("revision")
+ if (typeof revisionId !== "string") return undefined
+ let revision = await TldrawRevision.load(revisionId, {
+ resolve: {
+ snapshot: true,
+ lightPreview: true,
+ darkPreview: true,
+ },
+ })
+ return revision?.$isLoaded ? revision : undefined
+}
+
type TimeMachineRestoreParams = {
doc: LoadedDocument
historicalContent: string
+ historicalTimestamp: number
navigate: ReturnType
docId: string
+ restoreFailedMessage: string
}
function makeTimeMachineRestore(params: TimeMachineRestoreParams) {
- return function handleTimeMachineRestore() {
- let { doc, historicalContent, navigate, docId } = params
+ return async function handleTimeMachineRestore() {
+ let { doc, historicalContent, historicalTimestamp, navigate, docId } =
+ params
if (!doc.content) return
if (!canEdit(doc)) return
- applyContentDiffWithCommentAnchors(doc, historicalContent)
- doc.$jazz.set("updatedAt", new Date())
- syncDocumentMetadata(doc)
+ try {
+ await restoreAssetsAtTime(doc, historicalTimestamp)
+ applyContentDiffWithCommentAnchors(doc, historicalContent)
+ doc.$jazz.set("updatedAt", new Date())
+ syncDocumentMetadata(doc)
+ } catch (error) {
+ console.error("Failed to restore document version:", error)
+ toast.error(params.restoreFailedMessage)
+ return
+ }
navigate({
to: "/doc/$id",
diff --git a/src/schema/index.ts b/src/schema/index.ts
index e7d62d74..f17be33b 100644
--- a/src/schema/index.ts
+++ b/src/schema/index.ts
@@ -5,7 +5,13 @@ import { Document } from "@/app/features/documents/lib/schema"
import { Space } from "@/app/features/spaces/lib/schema"
import { runAccountMigration } from "@/schema/migrations"
-export { ImageAsset, VideoAsset, Asset } from "@/app/features/assets/lib/schema"
+export {
+ ImageAsset,
+ VideoAsset,
+ TldrawAsset,
+ TldrawRevision,
+ Asset,
+} from "@/app/features/assets/lib/schema"
export { Space } from "@/app/features/spaces/lib/schema"
export { createSpace } from "@/app/features/spaces/lib/create-space"
diff --git a/src/shared/intl/messages.common.ts b/src/shared/intl/messages.common.ts
index a1fe053e..03ad7c56 100644
--- a/src/shared/intl/messages.common.ts
+++ b/src/shared/intl/messages.common.ts
@@ -25,6 +25,28 @@ let baseCommonMessages = messages({
"assets.save": "Save",
"assets.nameRequired": "Name is required",
"assets.nameTooLong": "Name too long",
+ "assets.newWhiteboard": "New whiteboard",
+ "assets.editWhiteboard": "Edit whiteboard",
+ "assets.uploadOrImport": "Upload or import",
+ "assets.whiteboard": "Whiteboard",
+ "assets.importOneWhiteboard": "Import one whiteboard at a time",
+ "assets.multiplePagesUnsupported": "This tldraw file has multiple pages",
+ "assets.embeddedMediaUnsupported":
+ "Embedded images and media are not supported yet",
+ "assets.invalidTldraw": "This is not a valid tldraw file",
+ "assets.whiteboardEditorDescription":
+ "Edit the whiteboard, then save or cancel your changes.",
+ "assets.whiteboardLoading": "Whiteboard is still loading",
+ "assets.addBeforeSaving": "Add something to the whiteboard before saving",
+ "assets.whiteboardSaved": "Whiteboard saved",
+ "assets.whiteboardAdded": "Whiteboard added",
+ "assets.whiteboardSaveFailed": "Could not save the whiteboard",
+ "assets.discardWhiteboardTitle": "Discard whiteboard changes?",
+ "assets.discardWhiteboardDescription":
+ "Your changes since opening the whiteboard will be lost.",
+ "assets.discard": "Discard",
+ "assets.saving": "Saving…",
+ "assets.import": "Import",
"common.goHome": "Go Home",
"common.goBack": "Go back",
@@ -161,6 +183,30 @@ let deCommonMessages = translate(baseCommonMessages, {
"assets.save": "Speichern",
"assets.nameRequired": "Name erforderlich",
"assets.nameTooLong": "Name zu lang",
+ "assets.newWhiteboard": "Neues Whiteboard",
+ "assets.editWhiteboard": "Whiteboard bearbeiten",
+ "assets.uploadOrImport": "Hochladen oder importieren",
+ "assets.whiteboard": "Whiteboard",
+ "assets.importOneWhiteboard": "Importiere jeweils nur ein Whiteboard",
+ "assets.multiplePagesUnsupported":
+ "Diese tldraw-Datei enthält mehrere Seiten",
+ "assets.embeddedMediaUnsupported":
+ "Eingebettete Bilder und Medien werden noch nicht unterstützt",
+ "assets.invalidTldraw": "Dies ist keine gültige tldraw-Datei",
+ "assets.whiteboardEditorDescription":
+ "Bearbeite das Whiteboard und speichere oder verwirf anschließend deine Änderungen.",
+ "assets.whiteboardLoading": "Das Whiteboard wird noch geladen",
+ "assets.addBeforeSaving": "Füge vor dem Speichern etwas zum Whiteboard hinzu",
+ "assets.whiteboardSaved": "Whiteboard gespeichert",
+ "assets.whiteboardAdded": "Whiteboard hinzugefügt",
+ "assets.whiteboardSaveFailed":
+ "Das Whiteboard konnte nicht gespeichert werden",
+ "assets.discardWhiteboardTitle": "Whiteboard-Änderungen verwerfen?",
+ "assets.discardWhiteboardDescription":
+ "Deine Änderungen seit dem Öffnen des Whiteboards gehen verloren.",
+ "assets.discard": "Verwerfen",
+ "assets.saving": "Speichern…",
+ "assets.import": "Importieren",
"common.goHome": "Zur Startseite",
"common.goBack": "Zurück",
diff --git a/src/shared/intl/messages.documents.ts b/src/shared/intl/messages.documents.ts
index 4d55a589..215c247f 100644
--- a/src/shared/intl/messages.documents.ts
+++ b/src/shared/intl/messages.documents.ts
@@ -189,6 +189,7 @@ let baseDocumentsMessages = messages({
"timeMachine.clampedDescription":
"Edit {$edit} doesn't exist. Showing closest available version.",
"timeMachine.failedToCopyAsset": "Failed to copy asset: {$name}",
+ "timeMachine.restoreFailed": "Could not restore this version",
})
let deDocumentsMessages = translate(baseDocumentsMessages, {
@@ -383,4 +384,6 @@ let deDocumentsMessages = translate(baseDocumentsMessages, {
"timeMachine.clampedDescription":
"Bearbeitung {$edit} existiert nicht. Zeige die nächste verfügbare Version.",
"timeMachine.failedToCopyAsset": "Fehler beim Kopieren des Assets: {$name}",
+ "timeMachine.restoreFailed":
+ "Diese Version konnte nicht wiederhergestellt werden",
})
diff --git a/src/shared/intl/messages.editor.ts b/src/shared/intl/messages.editor.ts
index fe2f27cd..78c432f2 100644
--- a/src/shared/intl/messages.editor.ts
+++ b/src/shared/intl/messages.editor.ts
@@ -82,19 +82,29 @@ let baseEditorMessages = messages({
"editor.floating.changeLinkedDoc": "Change linked doc",
"editor.floating.removeLink": "Remove link",
"editor.floating.selectMedia": "Select media",
+ "editor.floating.addAsset": "Add media or whiteboard",
+ "editor.floating.new": "New",
+ "editor.floating.select": "Select",
"editor.dialog.linkToDocument": "Link to document",
"editor.dialog.searchDocuments": "Search for a document to link to",
"editor.dialog.noDocumentsFound": "No documents found",
"editor.dialog.changeLinkedDocument": "Change linked document",
"editor.dialog.selectMedia": "Select media",
+ "editor.dialog.media": "Media",
+ "editor.dialog.selectWhiteboard": "Select whiteboard",
+ "editor.dialog.searchWhiteboards": "Search your existing whiteboards",
+ "editor.dialog.noWhiteboardsFound": "No whiteboards found",
+ "editor.dialog.searchWhiteboardsPlaceholder": "Search whiteboards...",
"editor.dialog.searchMedia": "Search for an existing image or video",
"editor.dialog.noMediaFound": "No media found",
- "editor.dialog.upload": "Upload",
"editor.dialog.cancel": "Cancel",
+ "editor.dialog.close": "Close",
"editor.media.notAvailable": "Media not available",
"editor.media.loadingVideo": "Loading video...",
+ "editor.media.expandPreview": "Expand preview",
+ "editor.media.collapsePreview": "Collapse preview",
"editor.dialog.searchDocumentsPlaceholder": "Search documents...",
"editor.dialog.searchMediaPlaceholder": "Search media...",
@@ -187,19 +197,29 @@ let deEditorMessages = translate(baseEditorMessages, {
"editor.floating.changeLinkedDoc": "Verlinktes Dokument ändern",
"editor.floating.removeLink": "Link entfernen",
"editor.floating.selectMedia": "Media auswählen",
+ "editor.floating.addAsset": "Media oder Whiteboard hinzufügen",
+ "editor.floating.new": "Neu",
+ "editor.floating.select": "Auswählen",
"editor.dialog.linkToDocument": "Mit Dokument verlinken",
"editor.dialog.searchDocuments": "Suche nach einem Dokument zum Verlinken",
"editor.dialog.noDocumentsFound": "Keine Dokumente gefunden",
"editor.dialog.changeLinkedDocument": "Verlinktes Dokument ändern",
"editor.dialog.selectMedia": "Media auswählen",
+ "editor.dialog.media": "Media",
+ "editor.dialog.selectWhiteboard": "Whiteboard auswählen",
+ "editor.dialog.searchWhiteboards": "Vorhandene Whiteboards durchsuchen",
+ "editor.dialog.noWhiteboardsFound": "Keine Whiteboards gefunden",
+ "editor.dialog.searchWhiteboardsPlaceholder": "Whiteboards durchsuchen...",
"editor.dialog.searchMedia": "Suche nach einem existierenden Bild oder Video",
"editor.dialog.noMediaFound": "Keine Media gefunden",
- "editor.dialog.upload": "Hochladen",
"editor.dialog.cancel": "Abbrechen",
+ "editor.dialog.close": "Schließen",
"editor.media.notAvailable": "Media nicht verfügbar",
"editor.media.loadingVideo": "Lade Video...",
+ "editor.media.expandPreview": "Vorschau vergrößern",
+ "editor.media.collapsePreview": "Vorschau verkleinern",
"editor.dialog.searchDocumentsPlaceholder": "Dokumente suchen...",
"editor.dialog.searchMediaPlaceholder": "Medien suchen...",