diff --git a/client/modules/IDE/actions/project.js b/client/modules/IDE/actions/project.js index c303f691b1..eee268fa61 100644 --- a/client/modules/IDE/actions/project.js +++ b/client/modules/IDE/actions/project.js @@ -134,7 +134,8 @@ function getSynchedProject(currentState, responseProject) { export function saveProject( selectedFile = null, autosave = false, - mobile = false + mobile = false, + previewImage = null ) { return (dispatch, getState) => { const state = getState(); @@ -151,6 +152,9 @@ export function saveProject( } const formParams = Object.assign({}, state.project); formParams.files = [...state.files]; + if (previewImage) { + formParams.previewImage = previewImage; + } if (selectedFile) { const fileToUpdate = formParams.files.find( @@ -331,6 +335,9 @@ export function cloneProject(project) { { name: `${projectName} copy` }, { files: newFiles } ); + if (project?.previewImage) { + formParams.previewImage = project.previewImage; + } apiClient .post('/projects', formParams) .then((response) => { diff --git a/client/modules/IDE/components/IDEKeyHandlers.jsx b/client/modules/IDE/components/IDEKeyHandlers.jsx index 14e3cf2cba..7f74649f9f 100644 --- a/client/modules/IDE/components/IDEKeyHandlers.jsx +++ b/client/modules/IDE/components/IDEKeyHandlers.jsx @@ -19,6 +19,7 @@ import { getIsUserOwner, getSketchOwner } from '../selectors/users'; +import capturePreviewImage from '../utils/capturePreviewImage'; export const useIDEKeyHandlers = ({ getContent }) => { const dispatch = useDispatch(); @@ -40,11 +41,12 @@ export const useIDEKeyHandlers = ({ getContent }) => { }; useKeyDownHandlers({ - 'ctrl-s': (e) => { + 'ctrl-s': async (e) => { e.preventDefault(); e.stopPropagation(); if (isUserOwner || (isAuthenticated && !sketchOwner)) { - dispatch(saveProject(getContent())); + const previewImage = await capturePreviewImage(); + dispatch(saveProject(getContent(), false, false, previewImage)); } else if (isAuthenticated) { dispatch(cloneProject()); } else { diff --git a/client/modules/IDE/components/SketchList.jsx b/client/modules/IDE/components/SketchList.jsx index c7a8bdc6cd..72323b86a5 100644 --- a/client/modules/IDE/components/SketchList.jsx +++ b/client/modules/IDE/components/SketchList.jsx @@ -135,6 +135,9 @@ const SketchList = ({ > + + {t('SketchList.HeaderPreview')} + {renderFieldHeader('name', t('SketchList.HeaderName'))} {renderFieldHeader( 'createdAt', @@ -193,6 +196,7 @@ SketchList.propTypes = { name: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired, updatedAt: PropTypes.string.isRequired, + previewImage: PropTypes.string, visibility: PropTypes.string }) ).isRequired, diff --git a/client/modules/IDE/components/SketchListRowBase.jsx b/client/modules/IDE/components/SketchListRowBase.jsx index d11cb8bf40..66c25dc1c8 100644 --- a/client/modules/IDE/components/SketchListRowBase.jsx +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -120,8 +120,22 @@ const SketchListRowBase = ({ ); + const previewAlt = t('SketchList.PreviewAlt', { name: sketch.name }); + const previewCell = sketch.previewImage ? ( + {previewAlt} + ) : ( +
+ {t('SketchList.NoPreview')} +
+ ); + return ( + {previewCell} {name} {formatDateCell(sketch.createdAt, mobile)} {formatDateCell(sketch.updatedAt, mobile)} @@ -166,6 +180,7 @@ SketchListRowBase.propTypes = { name: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired, updatedAt: PropTypes.string.isRequired, + previewImage: PropTypes.string, visibility: PropTypes.string }).isRequired, username: PropTypes.string.isRequired, diff --git a/client/modules/IDE/hooks/useSketchActions.js b/client/modules/IDE/hooks/useSketchActions.js index a4c5d70235..ee2c28a96b 100644 --- a/client/modules/IDE/hooks/useSketchActions.js +++ b/client/modules/IDE/hooks/useSketchActions.js @@ -11,6 +11,7 @@ import { import { showToast } from '../actions/toast'; import { showErrorModal, showShareModal } from '../actions/ide'; import { selectCanEditSketch } from '../selectors/users'; +import capturePreviewImage from '../utils/capturePreviewImage'; const useSketchActions = () => { const unsavedChanges = useSelector((state) => state.ide.unsavedChanges); @@ -32,9 +33,12 @@ const useSketchActions = () => { } } - function saveSketch(cmController) { + async function saveSketch(cmController) { if (authenticated) { - dispatch(saveProject(cmController?.getContent())); + const previewImage = await capturePreviewImage(); + dispatch( + saveProject(cmController?.getContent(), false, false, previewImage) + ); } else { dispatch(showErrorModal('forceAuthentication')); } diff --git a/client/modules/IDE/reducers/project.js b/client/modules/IDE/reducers/project.js index 954a0b94e9..e5260113fa 100644 --- a/client/modules/IDE/reducers/project.js +++ b/client/modules/IDE/reducers/project.js @@ -8,6 +8,7 @@ const initialState = () => { return { name: generatedName, updatedAt: '', + previewImage: null, isSaving: false, visibility: 'Public' }; @@ -31,6 +32,7 @@ const project = (state, action) => { id: action.project.id, name: action.project.name, updatedAt: action.project.updatedAt, + previewImage: action.project.previewImage || null, owner: action.owner, isSaving: false, visibility: action.project.visibility @@ -40,6 +42,7 @@ const project = (state, action) => { id: action.project.id, name: action.project.name, updatedAt: action.project.updatedAt, + previewImage: action.project.previewImage || null, owner: action.owner, isSaving: false, visibility: action.project.visibility diff --git a/client/modules/IDE/utils/capturePreviewImage.js b/client/modules/IDE/utils/capturePreviewImage.js new file mode 100644 index 0000000000..1f59ff3f72 --- /dev/null +++ b/client/modules/IDE/utils/capturePreviewImage.js @@ -0,0 +1,31 @@ +import { dispatchMessage, MessageTypes } from '../../../utils/dispatcher'; + +const PREVIEW_RESPONSE_TIMEOUT = 2000; + +export default function capturePreviewImage() { + return new Promise((resolve) => { + let timer; + + function handleMessage(event) { + const { data } = event; + if (!data || data.type !== MessageTypes.PREVIEW_IMAGE) return; + + window.removeEventListener('message', handleMessage); + window.clearTimeout(timer); + + if (data?.payload?.image) { + resolve(data.payload.image); + } else { + resolve(null); + } + } + + timer = window.setTimeout(() => { + window.removeEventListener('message', handleMessage); + resolve(null); + }, PREVIEW_RESPONSE_TIMEOUT); + + window.addEventListener('message', handleMessage); + dispatchMessage({ type: MessageTypes.REQUEST_PREVIEW_IMAGE }); + }); +} diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.jsx index 2b6ac16720..96a2c55a12 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.jsx @@ -257,7 +257,14 @@ function getHtmlFile(files) { return files.filter((file) => file.name.match(/.*\.html$/i))[0]; } -function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { +function EmbedFrame({ + files, + isPlaying, + basePath, + gridOutput, + textOutput, + frameRef +}) { const iframe = useRef(); const htmlFile = useMemo(() => getHtmlFile(files), [files]); const srcRef = useRef(); @@ -272,6 +279,16 @@ function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { }; }); + useEffect(() => { + if (!frameRef) { + return () => {}; + } + frameRef.current = iframe.current; + return () => { + frameRef.current = null; + }; + }, [frameRef]); + function renderSketch() { const doc = iframe.current; if (isPlaying) { @@ -321,7 +338,14 @@ EmbedFrame.propTypes = { isPlaying: PropTypes.bool.isRequired, basePath: PropTypes.string.isRequired, gridOutput: PropTypes.bool.isRequired, - textOutput: PropTypes.bool.isRequired + textOutput: PropTypes.bool.isRequired, + frameRef: PropTypes.shape({ + current: PropTypes.instanceOf(Element) + }) +}; + +EmbedFrame.defaultProps = { + frameRef: null }; export default EmbedFrame; diff --git a/client/modules/Preview/previewIndex.jsx b/client/modules/Preview/previewIndex.jsx index 12c14e3b79..8d7d55951a 100644 --- a/client/modules/Preview/previewIndex.jsx +++ b/client/modules/Preview/previewIndex.jsx @@ -1,4 +1,10 @@ -import React, { useReducer, useState, useEffect } from 'react'; +import React, { + useReducer, + useState, + useEffect, + useRef, + useCallback +} from 'react'; import { render } from 'react-dom'; import { createGlobalStyle } from 'styled-components'; import { @@ -24,8 +30,55 @@ const App = () => { const [basePath, setBasePath] = useState(''); const [textOutput, setTextOutput] = useState(false); const [gridOutput, setGridOutput] = useState(false); + const sketchFrameRef = useRef(null); registerFrame(window.parent, getConfig('EDITOR_URL')); + const sendPreviewImage = useCallback(() => { + try { + const iframeDocument = sketchFrameRef.current?.contentDocument; + const canvas = iframeDocument?.querySelector('canvas'); + if (!canvas || !canvas.width || !canvas.height) { + dispatchMessage({ + type: MessageTypes.PREVIEW_IMAGE, + payload: { image: null } + }); + return; + } + + const maxDimension = 400; + const scale = Math.min( + maxDimension / canvas.width, + maxDimension / canvas.height, + 1 + ); + const targetWidth = Math.max(1, Math.floor(canvas.width * scale)); + const targetHeight = Math.max(1, Math.floor(canvas.height * scale)); + const previewCanvas = document.createElement('canvas'); + previewCanvas.width = targetWidth; + previewCanvas.height = targetHeight; + const ctx = previewCanvas.getContext('2d'); + if (!ctx) { + dispatchMessage({ + type: MessageTypes.PREVIEW_IMAGE, + payload: { image: null } + }); + return; + } + ctx.drawImage(canvas, 0, 0, targetWidth, targetHeight); + const image = previewCanvas.toDataURL('image/png'); + + dispatchMessage({ + type: MessageTypes.PREVIEW_IMAGE, + payload: { image } + }); + } catch (error) { + dispatchMessage({ + type: MessageTypes.PREVIEW_IMAGE, + payload: { image: null } + }); + } + }, []); + function handleMessageEvent(message) { const { type, payload } = message; switch (type) { @@ -47,6 +100,9 @@ const App = () => { case MessageTypes.EXECUTE: dispatchMessage(payload); break; + case MessageTypes.REQUEST_PREVIEW_IMAGE: + sendPreviewImage(); + break; default: break; } @@ -75,6 +131,7 @@ const App = () => { th:nth-child(1) { +.sketches-table__row>th:nth-child(2) { padding-left: #{math.div(12, $base-font-size)}rem; } @@ -242,6 +249,35 @@ align-items: center; } +.sketches-table__preview-header { + padding-left: #{math.div(12, $base-font-size)}rem; +} + +.sketches-table__preview-cell { + width: #{math.div(120, $base-font-size)}rem; +} + +.sketches-table__preview-image, +.sketches-table__preview-placeholder { + width: #{math.div(96, $base-font-size)}rem; + height: #{math.div(64, $base-font-size)}rem; + border-radius: #{math.div(6, $base-font-size)}rem; + object-fit: cover; + display: block; +} + +.sketches-table__preview-placeholder { + display: flex; + align-items: center; + justify-content: center; + font-size: #{math.div(12, $base-font-size)}rem; + @include themify() { + background: getThemifyVariable("search-background-color"); + color: getThemifyVariable("inactive-text-color"); + border: 1px dashed getThemifyVariable("modal-border-color"); + } +} + .sketches-table__icon-cell { width: #{math.div(35, $base-font-size)}rem; } @@ -256,4 +292,4 @@ color: getThemifyVariable("logo-color"); } text-decoration-thickness: 0.1em; -} \ No newline at end of file +} diff --git a/client/testData/testReduxStore.ts b/client/testData/testReduxStore.ts index daf663ef92..46ec978c8d 100644 --- a/client/testData/testReduxStore.ts +++ b/client/testData/testReduxStore.ts @@ -8,6 +8,7 @@ const mockProjects = [ _id: 'testid1', updatedAt: '2021-02-26T04:58:29', files: [], + previewImage: null, createdAt: '2021-02-26T04:58:14', id: 'testid1', visibility: 'Public' @@ -17,6 +18,7 @@ const mockProjects = [ _id: 'testid2', updatedAt: '2021-02-23T17:40:43', files: [], + previewImage: null, createdAt: '2021-02-23T17:40:43', id: 'testid2', visibility: 'Public' @@ -68,7 +70,8 @@ const initialTestState: RootState = { project: { name: 'Zealous sunflower', updatedAt: '', - isSaving: false + isSaving: false, + previewImage: null }, sketches: mockProjects, search: { diff --git a/client/utils/dispatcher.ts b/client/utils/dispatcher.ts index 4966a1620c..8e2c01f704 100644 --- a/client/utils/dispatcher.ts +++ b/client/utils/dispatcher.ts @@ -14,7 +14,9 @@ export enum MessageTypes { FILES = 'FILES', SKETCH = 'SKETCH', REGISTER = 'REGISTER', - EXECUTE = 'EXECUTE' + EXECUTE = 'EXECUTE', + REQUEST_PREVIEW_IMAGE = 'REQUEST_PREVIEW_IMAGE', + PREVIEW_IMAGE = 'PREVIEW_IMAGE' } /* eslint-enable no-shadow */ diff --git a/server/controllers/project.controller/getProjectsForUser.js b/server/controllers/project.controller/getProjectsForUser.js index 21eafc6370..0c6dc9e93b 100644 --- a/server/controllers/project.controller/getProjectsForUser.js +++ b/server/controllers/project.controller/getProjectsForUser.js @@ -33,7 +33,7 @@ const createCoreHandler = (mapProjectsToResponse) => async (req, res) => { const projects = await Project.find(filter) .sort('-createdAt') - .select('name files id createdAt updatedAt visibility') + .select('name files id createdAt updatedAt visibility previewImage') .exec(); const response = mapProjectsToResponse(projects); diff --git a/server/domain-objects/Project.js b/server/domain-objects/Project.js index d580670720..7f4d467ddd 100644 --- a/server/domain-objects/Project.js +++ b/server/domain-objects/Project.js @@ -131,7 +131,7 @@ export function toModel(object) { throw new FileValidationError("'files' must be an object"); } - const projectValues = pick(object, ['user', 'name', 'slug']); + const projectValues = pick(object, ['user', 'name', 'slug', 'previewImage']); projectValues.files = files; return new Project(projectValues); diff --git a/server/models/project.js b/server/models/project.js index 1cbcc78483..69946a5749 100644 --- a/server/models/project.js +++ b/server/models/project.js @@ -36,6 +36,7 @@ const projectSchema = new Schema( }, user: { type: Schema.Types.ObjectId, ref: 'User' }, serveSecure: { type: Boolean, default: false }, + previewImage: { type: String }, files: { type: [fileSchema] }, _id: { type: String, default: shortid.generate }, visibility: { @@ -93,7 +94,7 @@ projectSchema.statics.getProjectsForUserId = async function getProjectsForUserId return project .find({ user: userId }) .sort('-createdAt') - .select('name files id createdAt updatedAt') + .select('name files id createdAt updatedAt previewImage visibility') .exec(); }; diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json index 1d254a5afc..6682df546c 100644 --- a/translations/locales/en-US/translations.json +++ b/translations/locales/en-US/translations.json @@ -590,12 +590,15 @@ "ButtonLabelDescendingARIA": "Sort by {{displayName}} descending.", "AddToCollectionOverlayTitle": "Add to collection", "TableSummary": "table containing all saved projects", + "HeaderPreview": "Preview", "HeaderName": "Sketch", "HeaderCreatedAt": "Date Created", "HeaderCreatedAt_mobile": "Created", "HeaderUpdatedAt": "Date Updated", "HeaderUpdatedAt_mobile": "Updated", - "NoSketches": "No sketches." + "NoSketches": "No sketches.", + "NoPreview": "No preview", + "PreviewAlt": "{{name}} preview" }, "AddToCollectionSketchList": { "Title": "p5.js Web Editor | My sketches",