Feature: Implemented thumbnail.png saving and load. Projects on homepage will have images (#5133)
* feature: implemented saving thumbnail.png to have project thumbnails in the home page * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * Trigger CI * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * bump * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * bump * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * bump * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * bump * A snapshot a day keeps the bugs away! 📷🐛 (OS: namespace-profile-ubuntu-8-cores) * bump * Fix the failing test by increasing window height (related to toast covering now-larger project tiles) --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Pierre Jacquier <pierre@zoo.dev> Co-authored-by: Pierre Jacquier <pierrejacquier39@gmail.com> Co-authored-by: Frank Noirot <frank@zoo.dev>
@ -572,7 +572,7 @@ test(
|
||||
fs.utimesSync(`${dir}/lego/main.kcl`, _1995, _1995)
|
||||
})
|
||||
|
||||
await page.setBodyDimensions({ width: 1200, height: 500 })
|
||||
await page.setBodyDimensions({ width: 1200, height: 600 })
|
||||
|
||||
page.on('console', console.log)
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 74 KiB |
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 145 KiB |
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 129 KiB |
34
src/App.tsx
@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHotKeyListener } from './hooks/useHotKeyListener'
|
||||
import { Stream } from './components/Stream'
|
||||
import { AppHeader } from './components/AppHeader'
|
||||
@ -24,6 +24,10 @@ import { UnitsMenu } from 'components/UnitsMenu'
|
||||
import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
|
||||
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
||||
import { maybeWriteToDisk } from 'lib/telemetry'
|
||||
import { takeScreenshotOfVideoStreamCanvas } from 'lib/screenshot'
|
||||
import { writeProjectThumbnailFile } from 'lib/desktop'
|
||||
import { useRouteLoaderData } from 'react-router-dom'
|
||||
import { useEngineCommands } from 'components/EngineCommands'
|
||||
import { commandBarActor } from 'machines/commandBarMachine'
|
||||
import { useToken } from 'machines/appMachine'
|
||||
maybeWriteToDisk()
|
||||
@ -55,6 +59,12 @@ export function App() {
|
||||
|
||||
const projectName = project?.name || null
|
||||
const projectPath = project?.path || null
|
||||
|
||||
const [commands] = useEngineCommands()
|
||||
const [capturedCanvas, setCapturedCanvas] = useState(false)
|
||||
const loaderData = useRouteLoaderData(PATHS.FILE) as IndexLoaderData
|
||||
const lastCommandType = commands[commands.length - 1]?.type
|
||||
|
||||
useEffect(() => {
|
||||
onProjectOpen({ name: projectName, path: projectPath }, file || null)
|
||||
}, [projectName, projectPath])
|
||||
@ -92,6 +102,28 @@ export function App() {
|
||||
|
||||
useEngineConnectionSubscriptions()
|
||||
|
||||
// Generate thumbnail.png when loading the app
|
||||
useEffect(() => {
|
||||
if (!capturedCanvas && lastCommandType === 'execution-done') {
|
||||
setTimeout(() => {
|
||||
const projectDirectoryWithoutEndingSlash = loaderData?.project?.path
|
||||
if (!projectDirectoryWithoutEndingSlash) {
|
||||
return
|
||||
}
|
||||
const dataUrl: string = takeScreenshotOfVideoStreamCanvas()
|
||||
// zoom to fit command does not wait, wait 500ms to see if zoom to fit finishes
|
||||
writeProjectThumbnailFile(dataUrl, projectDirectoryWithoutEndingSlash)
|
||||
.then(() => {})
|
||||
.catch((e) => {
|
||||
console.error(
|
||||
`Failed to generate thumbnail for ${projectDirectoryWithoutEndingSlash}`
|
||||
)
|
||||
console.error(e)
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
}, [lastCommandType])
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-col" ref={ref}>
|
||||
<AppHeader
|
||||
|
@ -1,10 +1,8 @@
|
||||
import { useEngineCommands } from './EngineCommands'
|
||||
import { Spinner } from './Spinner'
|
||||
import { CustomIcon } from './CustomIcon'
|
||||
|
||||
export const ModelStateIndicator = () => {
|
||||
const [commands] = useEngineCommands()
|
||||
|
||||
const lastCommandType = commands[commands.length - 1]?.type
|
||||
|
||||
let className = 'w-6 h-6 '
|
||||
|
@ -2,7 +2,7 @@ import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { PATHS } from 'lib/paths'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ActionButton } from '../ActionButton'
|
||||
import { FILE_EXT } from 'lib/constants'
|
||||
import { FILE_EXT, PROJECT_IMAGE_NAME } from 'lib/constants'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import Tooltip from '../Tooltip'
|
||||
import { DeleteConfirmationDialog } from './DeleteProjectDialog'
|
||||
@ -29,7 +29,7 @@ function ProjectCard({
|
||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||
const [numberOfFiles, setNumberOfFiles] = useState(1)
|
||||
const [numberOfFolders, setNumberOfFolders] = useState(0)
|
||||
// const [imageUrl, setImageUrl] = useState('')
|
||||
const [imageUrl, setImageUrl] = useState('')
|
||||
|
||||
let inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@ -53,18 +53,21 @@ function ProjectCard({
|
||||
setNumberOfFolders(project.directory_count)
|
||||
}
|
||||
|
||||
// async function setupImageUrl() {
|
||||
// const projectImagePath = await join(project.file.path, PROJECT_IMAGE_NAME)
|
||||
// if (await exists(projectImagePath)) {
|
||||
// const imageData = await readFile(projectImagePath)
|
||||
// const blob = new Blob([imageData], { type: 'image/jpg' })
|
||||
// const imageUrl = URL.createObjectURL(blob)
|
||||
// setImageUrl(imageUrl)
|
||||
// }
|
||||
// }
|
||||
async function setupImageUrl() {
|
||||
const projectImagePath = window.electron.path.join(
|
||||
project.path,
|
||||
PROJECT_IMAGE_NAME
|
||||
)
|
||||
if (await window.electron.exists(projectImagePath)) {
|
||||
const imageData = await window.electron.readFile(projectImagePath)
|
||||
const blob = new Blob([imageData], { type: 'image/png' })
|
||||
const imageUrl = URL.createObjectURL(blob)
|
||||
setImageUrl(imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
void getNumberOfFiles()
|
||||
// void setupImageUrl()
|
||||
void setupImageUrl()
|
||||
}, [project.kcl_file_count, project.directory_count])
|
||||
|
||||
useEffect(() => {
|
||||
@ -84,7 +87,7 @@ function ProjectCard({
|
||||
to={`${PATHS.FILE}/${encodeURIComponent(project.default_file)}`}
|
||||
className="flex flex-col flex-1 !no-underline !text-chalkboard-110 dark:!text-chalkboard-10 group-hover:!hue-rotate-0 min-h-[5em] divide-y divide-primary/40 dark:divide-chalkboard-80 group-hover:!divide-primary"
|
||||
>
|
||||
{/* <div className="h-36 relative overflow-hidden bg-gradient-to-b from-transparent to-primary/10 rounded-t-sm">
|
||||
<div className="h-36 relative overflow-hidden bg-gradient-to-b from-transparent to-primary/10 rounded-t-sm">
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
@ -92,7 +95,7 @@ function ProjectCard({
|
||||
className="h-full w-full transition-transform group-hover:scale-105 object-cover"
|
||||
/>
|
||||
)}
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="pb-2 flex flex-col flex-grow flex-auto gap-2 rounded-b-sm">
|
||||
{isEditing ? (
|
||||
<ProjectCardRenameForm
|
||||
|
@ -26,7 +26,7 @@ export const FILE_EXT = '.kcl'
|
||||
/** Default file to open when a project is opened */
|
||||
export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const
|
||||
/** Thumbnail file name */
|
||||
export const PROJECT_IMAGE_NAME = `main.jpg` as const
|
||||
export const PROJECT_IMAGE_NAME = `thumbnail.png` as const
|
||||
/** The localStorage key for last-opened projects */
|
||||
export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
|
||||
/** The default name given to new kcl files in a project */
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
PROJECT_ENTRYPOINT,
|
||||
PROJECT_FOLDER,
|
||||
PROJECT_IMAGE_NAME,
|
||||
PROJECT_SETTINGS_FILE_NAME,
|
||||
SETTINGS_FILE_NAME,
|
||||
TELEMETRY_FILE_NAME,
|
||||
@ -625,3 +626,19 @@ export const getUser = async (
|
||||
}
|
||||
return Promise.reject(new Error('unreachable'))
|
||||
}
|
||||
|
||||
export const writeProjectThumbnailFile = async (
|
||||
dataUrl: string,
|
||||
projectDirectoryPath: string
|
||||
) => {
|
||||
const filePath = window.electron.path.join(
|
||||
projectDirectoryPath,
|
||||
PROJECT_IMAGE_NAME
|
||||
)
|
||||
const data = atob(dataUrl.substring('data:image/png;base64,'.length))
|
||||
const asArray = new Uint8Array(data.length)
|
||||
for (let i = 0, len = data.length; i < len; ++i) {
|
||||
asArray[i] = data.charCodeAt(i)
|
||||
}
|
||||
return window.electron.writeFile(filePath, asArray)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
function takeScreenshotOfVideoStreamCanvas() {
|
||||
export function takeScreenshotOfVideoStreamCanvas() {
|
||||
const canvas = document.querySelector('[data-engine]')
|
||||
const video = document.getElementById('video-stream')
|
||||
if (
|
||||
|