diff --git a/client/custom.d.ts b/client/custom.d.ts index 729f9e03de..44a91e944a 100644 --- a/client/custom.d.ts +++ b/client/custom.d.ts @@ -8,6 +8,41 @@ declare module '*.svg' { export default ReactComponent; } +declare module 'blob-util' { + export function createBlob(parts: any[], options?: { type: string }): Blob; + export function createObjectURL(blob: Blob): string; + export function revokeObjectURL(url: string): void; +} + +declare module 'mime' { + export function getType(path: string): string | null; +} + +declare module 'loop-protect' { + function loopProtect(code: string): string; + export = loopProtect; +} + +declare module 'jshint' { + export const JSHINT: { + (code: string): boolean; + errors: Array<{ + line: number; + character: number; + reason: string; + code: string; + }>; + }; +} + +declare module 'decomment' { + function decomment( + code: string, + options?: { ignore?: RegExp; space?: boolean } + ): string; + export = decomment; +} + // Extend window for Redux DevTools interface Window { __REDUX_DEVTOOLS_EXTENSION__?: () => any; diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.tsx similarity index 72% rename from client/modules/Preview/EmbedFrame.jsx rename to client/modules/Preview/EmbedFrame.tsx index 2b6ac16720..79b83ead0e 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.tsx @@ -1,5 +1,4 @@ import blobUtil from 'blob-util'; -import PropTypes from 'prop-types'; import React, { useRef, useEffect, useMemo } from 'react'; import styled from 'styled-components'; import loopProtect from 'loop-protect'; @@ -17,33 +16,45 @@ import { import { getAllScriptOffsets } from '../../utils/consoleUtils'; import { registerFrame } from '../../utils/dispatcher'; import { createBlobUrl } from './filesReducer'; +import type { File } from './filesReducer'; import resolvePathsForElementsWithAttribute from '../../../server/utils/resolveUtils'; -let objectUrls = {}; -let objectPaths = {}; +interface ObjectUrls { + [key: string]: string; +} + +interface ObjectPaths { + [key: string]: string; +} + +let objectUrls: ObjectUrls = {}; +let objectPaths: ObjectPaths = {}; + +interface FrameProps { + fullView?: boolean; +} -const Frame = styled.iframe` +const Frame = styled.iframe` min-height: 100%; min-width: 100%; position: absolute; border-width: 0; - ${({ fullView }) => + ${({ fullView }: FrameProps) => fullView && ` position: relative; `} `; -function resolveCSSLinksInString(content, files) { +function resolveCSSLinksInString(content: string, files: File[]): string { let newContent = content; - let cssFileStrings = content.match(STRING_REGEX); - cssFileStrings = cssFileStrings || []; + const cssFileStrings = content.match(STRING_REGEX) || []; cssFileStrings.forEach((cssFileString) => { if (cssFileString.match(MEDIA_FILE_QUOTED_REGEX)) { const filePath = cssFileString.substr(1, cssFileString.length - 2); const quoteCharacter = cssFileString.substr(0, 1); - const resolvedFile = resolvePathToFile(filePath, files); - if (resolvedFile) { + const resolvedFile = resolvePathToFile(filePath, files) as File | false; + if (resolvedFile && typeof resolvedFile === 'object') { if (resolvedFile.url) { newContent = newContent.replace( cssFileString, @@ -56,7 +67,7 @@ function resolveCSSLinksInString(content, files) { return newContent; } -function jsPreprocess(jsText) { +function jsPreprocess(jsText: string): string { let newContent = jsText; // check the code for js errors before sending it to strip comments // or loops. @@ -72,17 +83,16 @@ function jsPreprocess(jsText) { return newContent; } -function resolveJSLinksInString(content, files) { +function resolveJSLinksInString(content: string, files: File[]): string { let newContent = content; - let jsFileStrings = content.match(STRING_REGEX); - jsFileStrings = jsFileStrings || []; + const jsFileStrings = content.match(STRING_REGEX) || []; jsFileStrings.forEach((jsFileString) => { if (jsFileString.match(MEDIA_FILE_QUOTED_REGEX)) { const filePath = jsFileString.substr(1, jsFileString.length - 2); const quoteCharacter = jsFileString.substr(0, 1); - const resolvedFile = resolvePathToFile(filePath, files); + const resolvedFile = resolvePathToFile(filePath, files) as File | false; - if (resolvedFile) { + if (resolvedFile && typeof resolvedFile === 'object') { if (resolvedFile.url) { newContent = newContent.replace( jsFileString, @@ -101,16 +111,19 @@ function resolveJSLinksInString(content, files) { return jsPreprocess(newContent); } -function resolveScripts(sketchDoc, files) { +function resolveScripts(sketchDoc: Document, files: File[]): void { const scriptsInHTML = sketchDoc.getElementsByTagName('script'); const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML); - scriptsInHTMLArray.forEach((script) => { + scriptsInHTMLArray.forEach((script: HTMLScriptElement) => { if ( script.getAttribute('src') && - script.getAttribute('src').match(NOT_EXTERNAL_LINK_REGEX) !== null + script.getAttribute('src')!.match(NOT_EXTERNAL_LINK_REGEX) !== null ) { - const resolvedFile = resolvePathToFile(script.getAttribute('src'), files); - if (resolvedFile) { + const resolvedFile = resolvePathToFile( + script.getAttribute('src')!, + files + ) as File | false; + if (resolvedFile && typeof resolvedFile === 'object') { if (resolvedFile.url) { script.setAttribute('src', resolvedFile.url); } else { @@ -122,8 +135,10 @@ function resolveScripts(sketchDoc, files) { // objectUrls[blobUrl] = `${resolvedFile.filePath}${ // resolvedFile.filePath.length > 0 ? '/' : '' // }${resolvedFile.name}`; - objectUrls[blobUrl] = `${resolvedFile.filePath}/${resolvedFile.name}`; - objectPaths[blobPath] = resolvedFile.name; + objectUrls[blobUrl] = `${resolvedFile.filePath || ''}/${ + resolvedFile.name + }`; + objectPaths[blobPath!] = resolvedFile.name; // script.setAttribute('data-tag', `${startTag}${resolvedFile.name}`); // script.removeAttribute('src'); // script.innerHTML = resolvedFile.content; // eslint-disable-line @@ -132,7 +147,7 @@ function resolveScripts(sketchDoc, files) { } else if ( !( script.getAttribute('src') && - script.getAttribute('src').match(EXTERNAL_LINK_REGEX) + script.getAttribute('src')!.match(EXTERNAL_LINK_REGEX) ) !== null ) { script.setAttribute('crossorigin', ''); @@ -141,37 +156,40 @@ function resolveScripts(sketchDoc, files) { }); } -function resolveStyles(sketchDoc, files) { +function resolveStyles(sketchDoc: Document, files: File[]): void { const inlineCSSInHTML = sketchDoc.getElementsByTagName('style'); const inlineCSSInHTMLArray = Array.prototype.slice.call(inlineCSSInHTML); - inlineCSSInHTMLArray.forEach((style) => { + inlineCSSInHTMLArray.forEach((style: HTMLStyleElement) => { style.innerHTML = resolveCSSLinksInString(style.innerHTML, files); // eslint-disable-line }); const cssLinksInHTML = sketchDoc.querySelectorAll('link[rel="stylesheet"]'); const cssLinksInHTMLArray = Array.prototype.slice.call(cssLinksInHTML); - cssLinksInHTMLArray.forEach((css) => { + cssLinksInHTMLArray.forEach((css: HTMLLinkElement) => { if ( css.getAttribute('href') && - css.getAttribute('href').match(NOT_EXTERNAL_LINK_REGEX) !== null + css.getAttribute('href')!.match(NOT_EXTERNAL_LINK_REGEX) !== null ) { - const resolvedFile = resolvePathToFile(css.getAttribute('href'), files); - if (resolvedFile) { + const resolvedFile = resolvePathToFile( + css.getAttribute('href')!, + files + ) as File | false; + if (resolvedFile && typeof resolvedFile === 'object') { if (resolvedFile.url) { css.href = resolvedFile.url; // eslint-disable-line } else { const style = sketchDoc.createElement('style'); style.innerHTML = `\n${resolvedFile.content}`; sketchDoc.head.appendChild(style); - css.parentElement.removeChild(css); + css.parentElement!.removeChild(css); } } } }); } -function resolveJSAndCSSLinks(files) { - const newFiles = []; +function resolveJSAndCSSLinks(files: File[]): File[] { + const newFiles: File[] = []; files.forEach((file) => { const newFile = { ...file }; if (file.name.match(/.*\.js$/i)) { @@ -184,17 +202,27 @@ function resolveJSAndCSSLinks(files) { return newFiles; } -function addLoopProtect(sketchDoc) { +function addLoopProtect(sketchDoc: Document): void { const scriptsInHTML = sketchDoc.getElementsByTagName('script'); const scriptsInHTMLArray = Array.prototype.slice.call(scriptsInHTML); - scriptsInHTMLArray.forEach((script) => { + scriptsInHTMLArray.forEach((script: HTMLScriptElement) => { script.innerHTML = jsPreprocess(script.innerHTML); // eslint-disable-line }); } -function injectLocalFiles(files, htmlFile, options) { +interface InjectOptions { + basePath: string; + gridOutput: boolean; + textOutput: boolean; +} + +function injectLocalFiles( + files: File[], + htmlFile: File, + options: InjectOptions +): string { const { basePath, gridOutput, textOutput } = options; - let scriptOffs = []; + let scriptOffs: any[] = []; objectUrls = {}; objectPaths = {}; const resolvedFiles = resolveJSAndCSSLinks(files); @@ -202,7 +230,7 @@ function injectLocalFiles(files, htmlFile, options) { const sketchDoc = parser.parseFromString(htmlFile.content, 'text/html'); const base = sketchDoc.createElement('base'); - base.href = `${window.origin}${basePath}${basePath.length > 1 && '/'}`; + base.href = `${window.origin}${basePath}${basePath.length > 1 ? '/' : ''}`; sketchDoc.head.appendChild(base); resolvePathsForElementsWithAttribute('src', sketchDoc, resolvedFiles); @@ -253,18 +281,32 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);` return `\n${sketchDoc.documentElement.outerHTML}`; } -function getHtmlFile(files) { +function getHtmlFile(files: File[]): File { return files.filter((file) => file.name.match(/.*\.html$/i))[0]; } -function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { - const iframe = useRef(); +interface EmbedFrameProps { + files: File[]; + isPlaying: boolean; + basePath: string; + gridOutput: boolean; + textOutput: boolean; +} + +function EmbedFrame({ + files, + isPlaying, + basePath, + gridOutput, + textOutput +}: EmbedFrameProps) { + const iframe = useRef(null); const htmlFile = useMemo(() => getHtmlFile(files), [files]); - const srcRef = useRef(); + const srcRef = useRef(); useEffect(() => { const unsubscribe = registerFrame( - iframe.current.contentWindow, + iframe.current!.contentWindow!, window.origin ); return () => { @@ -273,16 +315,20 @@ function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { }); function renderSketch() { - const doc = iframe.current; + const doc = iframe.current!; if (isPlaying) { const htmlDoc = injectLocalFiles(files, htmlFile, { basePath, gridOutput, textOutput }); - const generatedHtmlFile = { + const generatedHtmlFile: File = { + id: 'generated', + _id: 'generated', name: 'index.html', - content: htmlDoc + content: htmlDoc, + fileType: 'file', + children: [] }; const htmlUrl = createBlobUrl(generatedHtmlFile); const toRevoke = srcRef.current; @@ -310,18 +356,5 @@ function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { ); } -EmbedFrame.propTypes = { - files: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - content: PropTypes.string.isRequired - }) - ).isRequired, - isPlaying: PropTypes.bool.isRequired, - basePath: PropTypes.string.isRequired, - gridOutput: PropTypes.bool.isRequired, - textOutput: PropTypes.bool.isRequired -}; - +// eslint-disable-next-line import/no-default-export export default EmbedFrame; diff --git a/client/modules/Preview/filesReducer.js b/client/modules/Preview/filesReducer.ts similarity index 51% rename from client/modules/Preview/filesReducer.js rename to client/modules/Preview/filesReducer.ts index 15534d6d7e..0d51dd3ce4 100644 --- a/client/modules/Preview/filesReducer.js +++ b/client/modules/Preview/filesReducer.ts @@ -3,13 +3,50 @@ import blobUtil from 'blob-util'; import mime from 'mime'; import { PLAINTEXT_FILE_REGEX } from '../../../server/utils/fileUtils'; +// Type the mime module to match custom.d.ts declaration +type MimeModule = { + getType(path: string): string | null; +}; + +// File interface based on the IDE files structure +export interface File { + id: string; + _id: string; + name: string; + content: string; + fileType: 'file' | 'folder'; + children: string[]; + url?: string; + blobUrl?: string; + filePath?: string; + isSelectedFile?: boolean; +} + +export type FileState = File[]; + +export interface FileSelectors { + getHTMLFile: () => File | undefined; + getJSFiles: () => File[]; + getCSSFiles: () => File[]; +} + +interface SetFilesAction { + type: 'SET_FILES'; + files: File[]; +} + +type FilesAction = SetFilesAction | { type: string }; + // https://gist.github.com/fnky/7d044b94070a35e552f3c139cdf80213 -export function useSelectors(state, mapStateToSelectors) { +export function useSelectors( + state: unknown, + mapStateToSelectors: (state: unknown) => T +): T { const selectors = useMemo(() => mapStateToSelectors(state), [state]); return selectors; } -export function getFileSelectors(state) { +export function getFileSelectors(state: FileState): FileSelectors { return { getHTMLFile: () => state.filter((file) => file.name.match(/.*\.html$/i))[0], getJSFiles: () => state.filter((file) => file.name.match(/.*\.js$/i)), @@ -17,32 +54,31 @@ export function getFileSelectors(state) { }; } -function sortedChildrenId(state, children) { +function sortedChildrenId(state: FileState, children: string[]): string[] { const childrenArray = state.filter((file) => children.includes(file.id)); childrenArray.sort((a, b) => (a.name > b.name ? 1 : -1)); return childrenArray.map((child) => child.id); } -export function setFiles(files) { +export function setFiles(files: File[]): SetFilesAction { return { type: 'SET_FILES', files }; } -export function createBlobUrl(file) { +export function createBlobUrl(file: File): string { if (file.blobUrl) { blobUtil.revokeObjectURL(file.blobUrl); } - - const mimeType = mime.getType(file.name) || 'text/plain'; - + const mimeType = + ((mime as unknown) as MimeModule).getType(file.name) || 'text/plain'; const fileBlob = blobUtil.createBlob([file.content], { type: mimeType }); const blobURL = blobUtil.createObjectURL(fileBlob); return blobURL; } -export function createBlobUrls(state) { +export function createBlobUrls(state: FileState): FileState { return state.map((file) => { if (file.name.match(PLAINTEXT_FILE_REGEX)) { const blobUrl = createBlobUrl(file); @@ -52,10 +88,10 @@ export function createBlobUrls(state) { }); } -export function filesReducer(state, action) { +export function filesReducer(state: FileState, action: FilesAction): FileState { switch (action.type) { case 'SET_FILES': - return createBlobUrls(action.files); + return createBlobUrls((action as SetFilesAction).files); default: return state.map((file) => { file.children = sortedChildrenId(state, file.children); diff --git a/client/modules/Preview/previewIndex.jsx b/client/modules/Preview/previewIndex.jsx deleted file mode 100644 index 12c14e3b79..0000000000 --- a/client/modules/Preview/previewIndex.jsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useReducer, useState, useEffect } from 'react'; -import { render } from 'react-dom'; -import { createGlobalStyle } from 'styled-components'; -import { - registerFrame, - listen, - MessageTypes, - dispatchMessage -} from '../../utils/dispatcher'; -import { filesReducer, setFiles } from './filesReducer'; -import EmbedFrame from './EmbedFrame'; -import { getConfig } from '../../utils/getConfig'; -import { initialState } from '../IDE/reducers/files'; - -const GlobalStyle = createGlobalStyle` - body { - margin: 0; - } -`; - -const App = () => { - const [state, dispatch] = useReducer(filesReducer, [], initialState); - const [isPlaying, setIsPlaying] = useState(false); - const [basePath, setBasePath] = useState(''); - const [textOutput, setTextOutput] = useState(false); - const [gridOutput, setGridOutput] = useState(false); - registerFrame(window.parent, getConfig('EDITOR_URL')); - - function handleMessageEvent(message) { - const { type, payload } = message; - switch (type) { - case MessageTypes.SKETCH: - dispatch(setFiles(payload.files)); - setBasePath(payload.basePath); - setTextOutput(payload.textOutput); - setGridOutput(payload.gridOutput); - break; - case MessageTypes.START: - setIsPlaying(true); - break; - case MessageTypes.STOP: - setIsPlaying(false); - break; - case MessageTypes.REGISTER: - dispatchMessage({ type: MessageTypes.REGISTER }); - break; - case MessageTypes.EXECUTE: - dispatchMessage(payload); - break; - default: - break; - } - } - - function addCacheBustingToAssets(files) { - const timestamp = new Date().getTime(); - return files.map((file) => { - if (file.url && !file.url.endsWith('obj') && !file.url.endsWith('stl')) { - return { - ...file, - url: `${file.url}?v=${timestamp}` - }; - } - return file; - }); - } - - useEffect(() => { - const unsubscribe = listen(handleMessageEvent); - return function cleanup() { - unsubscribe(); - }; - }); - return ( - - - - - ); -}; - -render(, document.getElementById('root')); diff --git a/client/modules/Preview/previewIndex.tsx b/client/modules/Preview/previewIndex.tsx new file mode 100644 index 0000000000..4735cd96c4 --- /dev/null +++ b/client/modules/Preview/previewIndex.tsx @@ -0,0 +1,73 @@ +import React, { useState, useEffect } from 'react'; +import ReactDOM from 'react-dom'; +import { + listen, + dispatchMessage, + registerFrame, + MessageTypes +} from '../../utils/dispatcher'; +import { getConfig } from '../../utils/getConfig'; +import EmbedFrame from './EmbedFrame'; +import { createBlobUrls } from './filesReducer'; +import type { File } from './filesReducer'; + +function PreviewApp() { + const [files, setFiles] = useState([]); + const [isPlaying, setIsPlaying] = useState(false); + const [basePath, setBasePath] = useState('/'); + const [gridOutput, setGridOutput] = useState(false); + const [textOutput, setTextOutput] = useState(false); + + useEffect(() => { + const editorOrigin = getConfig('EDITOR_URL') || ''; + const unsubscribe = registerFrame(window.parent, editorOrigin); + return () => unsubscribe(); + }, []); + + useEffect(() => { + function handleMessage(message: any) { + switch (message.type) { + case MessageTypes.REGISTER: + dispatchMessage({ type: MessageTypes.REGISTER }); + break; + case MessageTypes.SKETCH: { + const payload = message.payload || {}; + const incomingFiles: File[] = payload.files || []; + setFiles(createBlobUrls(incomingFiles)); + setBasePath(payload.basePath || '/'); + setGridOutput(Boolean(payload.gridOutput)); + setTextOutput(Boolean(payload.textOutput)); + break; + } + case MessageTypes.START: + setIsPlaying(true); + break; + case MessageTypes.STOP: + setIsPlaying(false); + break; + default: + break; + } + } + + const unsubscribe = listen(handleMessage); + return () => unsubscribe(); + }, []); + + return ( +
+ +
+ ); +} + +const rootEl = document.getElementById('root'); +if (rootEl) { + ReactDOM.render(, rootEl); +} diff --git a/client/tsconfig.json b/client/tsconfig.json index 9e63548a60..5b8aa44c5f 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -5,7 +5,7 @@ "module": "ESNext", "lib": ["DOM", "ESNext"], "jsx": "react", - }, - "include": ["./**/*"], + "types": ["node"] }, + "include": ["./**/*","custom.d.ts"], "exclude": ["../node_modules", "../server"] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 559bd82a4a..579a16fc06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -161,6 +161,7 @@ "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", + "@types/blob-util": "^1.3.3", "@types/classnames": "^2.3.0", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", @@ -15442,6 +15443,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/blob-util": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/blob-util/-/blob-util-1.3.3.tgz", + "integrity": "sha512-4ahcL/QDnpjWA2Qs16ZMQif7HjGP2cw3AGjHabybjw7Vm1EKu+cfQN1D78BaZbS1WJNa1opSMF5HNMztx7lR0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -50144,6 +50152,12 @@ "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", "dev": true }, + "@types/blob-util": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/blob-util/-/blob-util-1.3.3.tgz", + "integrity": "sha512-4ahcL/QDnpjWA2Qs16ZMQif7HjGP2cw3AGjHabybjw7Vm1EKu+cfQN1D78BaZbS1WJNa1opSMF5HNMztx7lR0w==", + "dev": true + }, "@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", diff --git a/package.json b/package.json index 873a60873d..34f0fd3d29 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "@testing-library/jest-dom": "^5.15.0", "@testing-library/react": "^12.1.2", "@types/bcryptjs": "^2.4.6", + "@types/blob-util": "^1.3.3", "@types/classnames": "^2.3.0", "@types/friendly-words": "^1.2.2", "@types/jest": "^29.5.14", diff --git a/webpack/config.dev.js b/webpack/config.dev.js index 85533fd7ae..7b810bae88 100644 --- a/webpack/config.dev.js +++ b/webpack/config.dev.js @@ -15,7 +15,7 @@ module.exports = { app: ['@gatsbyjs/webpack-hot-middleware/client', './client/index.jsx'], previewApp: [ '@gatsbyjs/webpack-hot-middleware/client', - './client/modules/Preview/previewIndex.jsx' + './client/modules/Preview/previewIndex.tsx' ], previewScripts: [ '@gatsbyjs/webpack-hot-middleware/client', diff --git a/webpack/config.prod.js b/webpack/config.prod.js index e125c621c5..665b04fa0d 100644 --- a/webpack/config.prod.js +++ b/webpack/config.prod.js @@ -21,7 +21,7 @@ module.exports = { ], previewApp: [ 'regenerator-runtime/runtime', - path.resolve(__dirname, '../client/modules/Preview/previewIndex.jsx') + path.resolve(__dirname, '../client/modules/Preview/previewIndex.tsx') ], previewScripts: [ 'regenerator-runtime/runtime',