Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

168 changes: 168 additions & 0 deletions examples/binary-files.fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import "../src/styles.css"
import { useEffect, useState } from "react"
import {
type EditorFile,
WorkspaceCodeEditor,
} from "../src/components/WorkspaceCodeEditor"
import { useWorkspaceFiles } from "../src/hooks/useWorkspaceFiles"

function createImageDataUrls() {
const canvas = document.createElement("canvas")
canvas.width = 1200
canvas.height = 720
const context = canvas.getContext("2d")!

// Leave a transparent margin to demonstrate the preview's checkerboard.
context.fillStyle = "#186c43"
context.fillRect(100, 100, 1000, 520)
context.strokeStyle = "#e7c66b"
context.lineWidth = 16
context.strokeRect(180, 180, 840, 360)
context.fillStyle = "#162e26"
context.fillRect(440, 240, 320, 240)
context.fillStyle = "#ffffff"
context.font = "24px sans-serif"
context.textAlign = "center"
context.fillText("Sample board", 600, 368)

const png = canvas.toDataURL("image/png")
// JPEG has no alpha channel; give it an opaque background.
context.globalCompositeOperation = "destination-over"
context.fillStyle = "#f8fafc"
context.fillRect(0, 0, canvas.width, canvas.height)
const jpeg = canvas.toDataURL("image/jpeg")

return { png, jpeg }
}

function BinaryFilesFixture({
initialCurrentFile,
}: {
initialCurrentFile: string
}) {
const [files, setFiles] = useState<EditorFile[] | null>(null)

useEffect(() => {
const { png, jpeg } = createImageDataUrls()
const pngBytes = Uint8Array.from(atob(png.split(",")[1]!), (char) =>
char.charCodeAt(0),
)
const blobUrl = URL.createObjectURL(
new Blob([pngBytes], { type: "image/png" }),
)
const stepUrl = URL.createObjectURL(
new Blob(["ISO-10303-21;\nEND-ISO-10303-21;"], {
type: "application/octet-stream",
}),
)
const largeFileUrl = URL.createObjectURL(
new Blob([pngBytes, new Uint8Array(5 * 1024 * 1024)], {
type: "image/png",
}),
)
const largeDimensionsUrl = URL.createObjectURL(
new Blob(
[
'<svg xmlns="http://www.w3.org/2000/svg" width="100000" height="100000"><rect width="100000" height="100000" fill="#186c43" /></svg>',
],
{ type: "image/svg+xml" },
),
)

setFiles([
{
path: "board.PNG",
content: blobUrl,
isBinary: true,
downloadUrl: png,
},
{ path: "board.jpg", content: jpeg, isBinary: true },
{
path: "download-only.png",
content: "",
isBinary: true,
downloadUrl: png,
},
{
path: "broken.png",
content: "data:image/png;base64,broken",
isBinary: true,
downloadUrl: png,
},
{ path: "missing.png", content: "", isBinary: true },
{ path: "large-file.png", content: largeFileUrl, isBinary: true },
{
path: "large-dimensions.svg",
content: largeDimensionsUrl,
isBinary: true,
},
{
path: "board.step",
content: stepUrl,
isBinary: true,
downloadUrl: stepUrl,
},
{
path: "board.svg",
content:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 72">\n <rect x="10" y="10" width="100" height="52" fill="#186c43" />\n</svg>\n',
},
{
path: "index.tsx",
content: 'export const message = "Switch between images and code"\n',
},
])

return () => {
URL.revokeObjectURL(blobUrl)
URL.revokeObjectURL(stepUrl)
URL.revokeObjectURL(largeFileUrl)
URL.revokeObjectURL(largeDimensionsUrl)
}
}, [])

if (!files) return null

return (
<ExampleWorkspace
key={files[0]?.content}
initialFiles={files}
initialCurrentFile={initialCurrentFile}
/>
)
}

function ExampleWorkspace({
initialFiles,
initialCurrentFile,
}: {
initialFiles: EditorFile[]
initialCurrentFile: string
}) {
const workspace = useWorkspaceFiles({ initialFiles, initialCurrentFile })

return (
<div className="h-screen bg-white">
<WorkspaceCodeEditor
{...workspace}
enableTypeScriptLanguageService={false}
/>
</div>
)
}

export default {
"PNG blob URL": <BinaryFilesFixture initialCurrentFile="board.PNG" />,
"JPEG data URL": <BinaryFilesFixture initialCurrentFile="board.jpg" />,
"PNG download URL": (
<BinaryFilesFixture initialCurrentFile="download-only.png" />
),
"Failed image": <BinaryFilesFixture initialCurrentFile="broken.png" />,
"Missing image URL": <BinaryFilesFixture initialCurrentFile="missing.png" />,
"Unsupported binary": <BinaryFilesFixture initialCurrentFile="board.step" />,
"Editable SVG": <BinaryFilesFixture initialCurrentFile="board.svg" />,
"Large file": <BinaryFilesFixture initialCurrentFile="large-file.png" />,
"Large dimensions": (
<BinaryFilesFixture initialCurrentFile="large-dimensions.svg" />
),
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"@typescript/ata": "^0.9.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"image-size": "^2.0.2",
"lucide-react": "^1.21.0",
"monaco-editor": "^0.55.1",
"radix-ui": "^1.6.0",
Expand Down
117 changes: 117 additions & 0 deletions src/components/BinaryFilePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useEffect, useState } from "react"
import { getImagePreviewUrl } from "../utils/getImagePreviewUrl"
import {
ImagePreviewLimitError,
loadImagePreview,
} from "../utils/loadImagePreview"
import type { EditorFile } from "./WorkspaceCodeEditor"

export function BinaryFilePreview({ file }: { file: EditorFile }) {
const imageUrl = getImagePreviewUrl(file)
const [preview, setPreview] = useState<{
source: string
url?: string
error?: "large" | "unavailable"
} | null>(null)
const downloadUrl = file.downloadUrl || imageUrl

useEffect(() => {
if (!imageUrl) return
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 10_000)
let active = true
let objectUrl: string | undefined
void loadImagePreview(imageUrl, controller.signal)
.then((blob) => {
if (!active) return
objectUrl = URL.createObjectURL(blob)
setPreview({ source: imageUrl, url: objectUrl })
})
.catch((error) => {
if (!active) return
setPreview({
source: imageUrl,
error:
error instanceof ImagePreviewLimitError ? "large" : "unavailable",
})
})
.finally(() => clearTimeout(timeout))
return () => {
active = false
clearTimeout(timeout)
controller.abort()
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [imageUrl])

const currentPreview = preview?.source === imageUrl ? preview : null
if (!currentPreview?.url) {
const status = !imageUrl ? "binary" : (currentPreview?.error ?? "loading")
const [title, message] = {
binary: ["Binary file", "This file cannot be displayed in the editor."],
large: [
"Image too large",
"This image is too large to preview. Download it to view.",
],
unavailable: [
"Image preview unavailable",
"This image could not be loaded.",
],
loading: ["Loading image preview…", "Checking image size…"],
}[status]
return (
<div className="flex h-full flex-col items-center justify-center gap-3 font-sans text-slate-500">
<p className="font-semibold">{title}</p>
<p className="text-[13px]">{message}</p>
{downloadUrl && <DownloadLink url={downloadUrl} path={file.path} />}
</div>
)
}

return (
<div className="flex h-full flex-col font-sans">
<div
className="min-h-0 flex-1 overflow-hidden"
style={{
backgroundColor: "#f8fafc",
backgroundImage:
"repeating-conic-gradient(#e2e8f0 0% 25%, transparent 0% 50%)",
backgroundSize: "16px 16px",
}}
>
<img
src={currentPreview.url}
alt={file.path}
decoding="async"
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "contain",
padding: "16px",
}}
onError={() =>
setPreview({ source: imageUrl!, error: "unavailable" })
}
/>
</div>
{downloadUrl && (
<div className="flex shrink-0 justify-center border-t border-gray-200 p-2">
<DownloadLink url={downloadUrl} path={file.path} />
</div>
)}
</div>
)
}

function DownloadLink({ url, path }: { url: string; path: string }) {
return (
<a
href={url}
download={path.split("/").pop()}
className="text-sm text-blue-600 underline underline-offset-2 hover:text-blue-700"
>
Download file
</a>
)
}
35 changes: 10 additions & 25 deletions src/components/WorkspaceCodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
applyWorkspaceReplacements,
type WorkspaceSearchMatch,
} from "../utils/workspaceSearch"
import { BinaryFilePreview } from "./BinaryFilePreview"
import { Breadcrumbs } from "./Breadcrumbs"
import { FileSidebar } from "./FileSidebar"
import { QuickOpen } from "./QuickOpen"
Expand All @@ -37,6 +38,7 @@ import { WorkspaceSearch } from "./WorkspaceSearch"

export type EditorFile = {
path: string
/** Text content, or a blob/data URL for a binary file. */
content: string
isBinary?: boolean
downloadUrl?: string
Expand Down Expand Up @@ -251,16 +253,19 @@ export const WorkspaceCodeEditor = forwardRef<

let editorBody: React.ReactNode

if (isStreaming) {
if (currentEditorFile?.isBinary) {
editorBody = (
<BinaryFilePreview
key={currentEditorFile.path}
file={currentEditorFile}
/>
)
} else if (isStreaming) {
editorBody = (
<pre className="m-0 h-full overflow-auto whitespace-pre-wrap p-4 font-mono text-xs">
{currentContent}
</pre>
)
} else if (currentFileIsBinary) {
editorBody = (
<BinaryFileNotice downloadUrl={currentEditorFile?.downloadUrl} />
)
} else if (!isReady) {
editorBody = <CenteredMessage>Loading editor…</CenteredMessage>
} else if (currentFile) {
Expand Down Expand Up @@ -378,23 +383,3 @@ function CenteredMessage({ children }: { children: React.ReactNode }) {
</div>
)
}

function BinaryFileNotice({ downloadUrl }: { downloadUrl?: string }) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 font-sans text-slate-500">
<p className="font-semibold">Binary file</p>
<p className="text-[13px]">
This file cannot be displayed in the editor.
</p>
{downloadUrl && (
<a
href={downloadUrl}
download
className="text-sm text-blue-600 underline underline-offset-2 hover:text-blue-700"
>
Download file
</a>
)}
</div>
)
}
16 changes: 16 additions & 0 deletions src/utils/getImagePreviewUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { EditorFile } from "../components/WorkspaceCodeEditor"

export function getImagePreviewUrl(file: EditorFile): string | null {
if (
!file.isBinary ||
!/\.(png|apng|jpe?g|gif|webp|avif|bmp|ico|svg)$/i.test(file.path)
) {
return null
}

// Package loaders put the already-fetched binary in a blob URL. Prefer it
// because fetching downloadUrl again may require authorization headers.
if (/^(blob:|data:image\/)/i.test(file.content)) return file.content

return file.downloadUrl || null
}
Loading
Loading