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)
|
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)
|
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 { useHotKeyListener } from './hooks/useHotKeyListener'
|
||||||
import { Stream } from './components/Stream'
|
import { Stream } from './components/Stream'
|
||||||
import { AppHeader } from './components/AppHeader'
|
import { AppHeader } from './components/AppHeader'
|
||||||
@ -24,6 +24,10 @@ import { UnitsMenu } from 'components/UnitsMenu'
|
|||||||
import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
|
import { CameraProjectionToggle } from 'components/CameraProjectionToggle'
|
||||||
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
import { useCreateFileLinkQuery } from 'hooks/useCreateFileLinkQueryWatcher'
|
||||||
import { maybeWriteToDisk } from 'lib/telemetry'
|
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 { commandBarActor } from 'machines/commandBarMachine'
|
||||||
import { useToken } from 'machines/appMachine'
|
import { useToken } from 'machines/appMachine'
|
||||||
maybeWriteToDisk()
|
maybeWriteToDisk()
|
||||||
@ -55,6 +59,12 @@ export function App() {
|
|||||||
|
|
||||||
const projectName = project?.name || null
|
const projectName = project?.name || null
|
||||||
const projectPath = project?.path || 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(() => {
|
useEffect(() => {
|
||||||
onProjectOpen({ name: projectName, path: projectPath }, file || null)
|
onProjectOpen({ name: projectName, path: projectPath }, file || null)
|
||||||
}, [projectName, projectPath])
|
}, [projectName, projectPath])
|
||||||
@ -92,6 +102,28 @@ export function App() {
|
|||||||
|
|
||||||
useEngineConnectionSubscriptions()
|
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 (
|
return (
|
||||||
<div className="relative h-full flex flex-col" ref={ref}>
|
<div className="relative h-full flex flex-col" ref={ref}>
|
||||||
<AppHeader
|
<AppHeader
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { useEngineCommands } from './EngineCommands'
|
import { useEngineCommands } from './EngineCommands'
|
||||||
import { Spinner } from './Spinner'
|
import { Spinner } from './Spinner'
|
||||||
import { CustomIcon } from './CustomIcon'
|
import { CustomIcon } from './CustomIcon'
|
||||||
|
|
||||||
export const ModelStateIndicator = () => {
|
export const ModelStateIndicator = () => {
|
||||||
const [commands] = useEngineCommands()
|
const [commands] = useEngineCommands()
|
||||||
|
|
||||||
const lastCommandType = commands[commands.length - 1]?.type
|
const lastCommandType = commands[commands.length - 1]?.type
|
||||||
|
|
||||||
let className = 'w-6 h-6 '
|
let className = 'w-6 h-6 '
|
||||||
|
@ -2,7 +2,7 @@ import { FormEvent, useEffect, useRef, useState } from 'react'
|
|||||||
import { PATHS } from 'lib/paths'
|
import { PATHS } from 'lib/paths'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ActionButton } from '../ActionButton'
|
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 { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import Tooltip from '../Tooltip'
|
import Tooltip from '../Tooltip'
|
||||||
import { DeleteConfirmationDialog } from './DeleteProjectDialog'
|
import { DeleteConfirmationDialog } from './DeleteProjectDialog'
|
||||||
@ -29,7 +29,7 @@ function ProjectCard({
|
|||||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false)
|
||||||
const [numberOfFiles, setNumberOfFiles] = useState(1)
|
const [numberOfFiles, setNumberOfFiles] = useState(1)
|
||||||
const [numberOfFolders, setNumberOfFolders] = useState(0)
|
const [numberOfFolders, setNumberOfFolders] = useState(0)
|
||||||
// const [imageUrl, setImageUrl] = useState('')
|
const [imageUrl, setImageUrl] = useState('')
|
||||||
|
|
||||||
let inputRef = useRef<HTMLInputElement>(null)
|
let inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@ -53,18 +53,21 @@ function ProjectCard({
|
|||||||
setNumberOfFolders(project.directory_count)
|
setNumberOfFolders(project.directory_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
// async function setupImageUrl() {
|
async function setupImageUrl() {
|
||||||
// const projectImagePath = await join(project.file.path, PROJECT_IMAGE_NAME)
|
const projectImagePath = window.electron.path.join(
|
||||||
// if (await exists(projectImagePath)) {
|
project.path,
|
||||||
// const imageData = await readFile(projectImagePath)
|
PROJECT_IMAGE_NAME
|
||||||
// const blob = new Blob([imageData], { type: 'image/jpg' })
|
)
|
||||||
// const imageUrl = URL.createObjectURL(blob)
|
if (await window.electron.exists(projectImagePath)) {
|
||||||
// setImageUrl(imageUrl)
|
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 getNumberOfFiles()
|
||||||
// void setupImageUrl()
|
void setupImageUrl()
|
||||||
}, [project.kcl_file_count, project.directory_count])
|
}, [project.kcl_file_count, project.directory_count])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -84,7 +87,7 @@ function ProjectCard({
|
|||||||
to={`${PATHS.FILE}/${encodeURIComponent(project.default_file)}`}
|
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"
|
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 && (
|
{imageUrl && (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
@ -92,7 +95,7 @@ function ProjectCard({
|
|||||||
className="h-full w-full transition-transform group-hover:scale-105 object-cover"
|
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">
|
<div className="pb-2 flex flex-col flex-grow flex-auto gap-2 rounded-b-sm">
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<ProjectCardRenameForm
|
<ProjectCardRenameForm
|
||||||
|
@ -26,7 +26,7 @@ export const FILE_EXT = '.kcl'
|
|||||||
/** Default file to open when a project is opened */
|
/** Default file to open when a project is opened */
|
||||||
export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const
|
export const PROJECT_ENTRYPOINT = `main${FILE_EXT}` as const
|
||||||
/** Thumbnail file name */
|
/** 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 */
|
/** The localStorage key for last-opened projects */
|
||||||
export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
|
export const FILE_PERSIST_KEY = `${PROJECT_FOLDER}-last-opened` as const
|
||||||
/** The default name given to new kcl files in a project */
|
/** The default name given to new kcl files in a project */
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
PROJECT_ENTRYPOINT,
|
PROJECT_ENTRYPOINT,
|
||||||
PROJECT_FOLDER,
|
PROJECT_FOLDER,
|
||||||
|
PROJECT_IMAGE_NAME,
|
||||||
PROJECT_SETTINGS_FILE_NAME,
|
PROJECT_SETTINGS_FILE_NAME,
|
||||||
SETTINGS_FILE_NAME,
|
SETTINGS_FILE_NAME,
|
||||||
TELEMETRY_FILE_NAME,
|
TELEMETRY_FILE_NAME,
|
||||||
@ -625,3 +626,19 @@ export const getUser = async (
|
|||||||
}
|
}
|
||||||
return Promise.reject(new Error('unreachable'))
|
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 canvas = document.querySelector('[data-engine]')
|
||||||
const video = document.getElementById('video-stream')
|
const video = document.getElementById('video-stream')
|
||||||
if (
|
if (
|
||||||
|