diff --git a/client/common/Button.tsx b/client/common/Button.tsx index d52abf3140..bde6106b00 100644 --- a/client/common/Button.tsx +++ b/client/common/Button.tsx @@ -80,7 +80,7 @@ export interface ButtonProps extends React.HTMLAttributes { /** * If using a native button, specifies on an onClick action */ - onClick?: () => void; + onClick?: (evt: React.MouseEvent) => void; /** * If using a button, then type is defines the type of button */ diff --git a/client/common/useSyncFormTranslations.ts b/client/common/useSyncFormTranslations.ts index 4a90362750..3212117399 100644 --- a/client/common/useSyncFormTranslations.ts +++ b/client/common/useSyncFormTranslations.ts @@ -1,30 +1,30 @@ import { useEffect, MutableRefObject } from 'react'; +import type { FormApi } from 'final-form'; -export interface FormLike { - getState(): { values: Record }; - reset(): void; - change(field: string, value: unknown): void; -} +// Generic FormLike that mirrors FormApi for any form value type +export interface FormLike> + extends Pick, 'getState' | 'reset' | 'change'> {} /** * This hook ensures that form values are preserved when the language changes. * @param formRef * @param language */ -export const useSyncFormTranslations = ( - formRef: MutableRefObject, +export const useSyncFormTranslations = >( + formRef: MutableRefObject | null>, language: string ) => { useEffect(() => { - const form = formRef.current; + const form = formRef?.current; if (!form) return; const { values } = form.getState(); form.reset(); - Object.keys(values).forEach((field) => { - if (values[field]) { - form.change(field, values[field]); + (Object.keys(values) as (keyof FormValues)[]).forEach((field) => { + const value = values[field]; + if (value !== undefined && value !== null && value !== '') { + form.change(field, value); } }); }, [language]); diff --git a/client/custom.d.ts b/client/custom.d.ts index 729f9e03de..20a6cf6ed7 100644 --- a/client/custom.d.ts +++ b/client/custom.d.ts @@ -19,3 +19,5 @@ interface NodeModule { accept(path?: string, callback?: () => void): void; }; } + +declare module 'loop-protect'; diff --git a/client/modules/App/App.jsx b/client/modules/App/App.jsx index 53b0c82c63..242b5244c8 100644 --- a/client/modules/App/App.jsx +++ b/client/modules/App/App.jsx @@ -6,7 +6,7 @@ import { showReduxDevTools } from '../../store'; import DevTools from './components/DevTools'; import { setPreviousPath } from '../IDE/actions/ide'; import { setLanguage } from '../IDE/actions/preferences'; -import CookieConsent from '../User/components/CookieConsent'; +import { CookieConsent } from '../User/components/CookieConsent'; function hideCookieConsent(pathname) { if (pathname.includes('/full/') || pathname.includes('/embed/')) { diff --git a/client/modules/IDE/components/Header/Toolbar.jsx b/client/modules/IDE/components/Header/Toolbar.jsx index fbf0b5d091..16ed74ff3e 100644 --- a/client/modules/IDE/components/Header/Toolbar.jsx +++ b/client/modules/IDE/components/Header/Toolbar.jsx @@ -20,7 +20,7 @@ import StopIcon from '../../../../images/stop.svg'; import PreferencesIcon from '../../../../images/preferences.svg'; import ProjectName from './ProjectName'; import VersionIndicator from '../VersionIndicator'; -import VisibilityDropdown from '../../../User/components/VisibilityDropdown'; +import { VisibilityDropdown } from '../../../User/components/VisibilityDropdown'; import { changeVisibility } from '../../actions/project'; const Toolbar = (props) => { diff --git a/client/modules/IDE/components/SketchListRowBase.jsx b/client/modules/IDE/components/SketchListRowBase.jsx index e1c4c80f13..d11cb8bf40 100644 --- a/client/modules/IDE/components/SketchListRowBase.jsx +++ b/client/modules/IDE/components/SketchListRowBase.jsx @@ -10,7 +10,7 @@ import { TableDropdown } from '../../../components/Dropdown/TableDropdown'; import { MenuItem } from '../../../components/Dropdown/MenuItem'; import { formatDateToString } from '../../../utils/formatDate'; import { getConfig } from '../../../utils/getConfig'; -import VisibilityDropdown from '../../User/components/VisibilityDropdown'; +import { VisibilityDropdown } from '../../User/components/VisibilityDropdown'; const ROOT_URL = getConfig('API_URL'); diff --git a/client/modules/Preview/EmbedFrame.jsx b/client/modules/Preview/EmbedFrame.tsx similarity index 66% rename from client/modules/Preview/EmbedFrame.jsx rename to client/modules/Preview/EmbedFrame.tsx index 2b6ac16720..7ea0d382fa 100644 --- a/client/modules/Preview/EmbedFrame.jsx +++ b/client/modules/Preview/EmbedFrame.tsx @@ -16,13 +16,13 @@ import { } from '../../../server/utils/fileUtils'; import { getAllScriptOffsets } from '../../utils/consoleUtils'; import { registerFrame } from '../../utils/dispatcher'; -import { createBlobUrl } from './filesReducer'; +import { createBlobUrl, File } from './filesReducer'; import resolvePathsForElementsWithAttribute from '../../../server/utils/resolveUtils'; -let objectUrls = {}; -let objectPaths = {}; +let objectUrls: Record = {}; +let objectPaths: Record = {}; -const Frame = styled.iframe` +const Frame = styled.iframe<{ fullView?: boolean }>` min-height: 100%; min-width: 100%; position: absolute; @@ -34,29 +34,32 @@ const Frame = styled.iframe` `} `; -function resolveCSSLinksInString(content, files) { +function resolveCSSLinksInString(content: string, files: File[]): string { let newContent = content; - let cssFileStrings = content.match(STRING_REGEX); - cssFileStrings = cssFileStrings || []; - 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) { - if (resolvedFile.url) { - newContent = newContent.replace( - cssFileString, - quoteCharacter + resolvedFile.url + quoteCharacter - ); + const cssFileStrings = content.match(STRING_REGEX); + if (cssFileStrings) { + 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) as + | File + | undefined; + if (resolvedFile && typeof resolvedFile === 'object') { + if (resolvedFile.url) { + newContent = newContent.replace( + cssFileString, + quoteCharacter + resolvedFile.url + quoteCharacter + ); + } } } - } - }); + }); + } 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,36 +75,39 @@ 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 || []; - 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 jsFileStrings = content.match(STRING_REGEX); + if (jsFileStrings) { + 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) as + | File + | undefined; - if (resolvedFile) { - if (resolvedFile.url) { - newContent = newContent.replace( - jsFileString, - quoteCharacter + resolvedFile.url + quoteCharacter - ); - } else if (resolvedFile.name.match(PLAINTEXT_FILE_REGEX)) { - newContent = newContent.replace( - jsFileString, - quoteCharacter + resolvedFile.blobUrl + quoteCharacter - ); + if (resolvedFile && typeof resolvedFile === 'object') { + if (resolvedFile.url) { + newContent = newContent.replace( + jsFileString, + quoteCharacter + resolvedFile.url + quoteCharacter + ); + } else if (resolvedFile.name.match(PLAINTEXT_FILE_REGEX)) { + newContent = newContent.replace( + jsFileString, + quoteCharacter + resolvedFile.blobUrl + quoteCharacter + ); + } } } - } - }); + }); + } 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) => { @@ -109,8 +115,11 @@ function resolveScripts(sketchDoc, files) { script.getAttribute('src') && 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 | undefined; + if (resolvedFile && typeof resolvedFile === 'object') { if (resolvedFile.url) { script.setAttribute('src', resolvedFile.url); } else { @@ -118,7 +127,7 @@ function resolveScripts(sketchDoc, files) { // that changed const blobUrl = createBlobUrl(resolvedFile); script.setAttribute('src', blobUrl); - const blobPath = blobUrl.split('/').pop(); + const blobPath = blobUrl.split('/').pop() || ''; // objectUrls[blobUrl] = `${resolvedFile.filePath}${ // resolvedFile.filePath.length > 0 ? '/' : '' // }${resolvedFile.name}`; @@ -141,7 +150,7 @@ 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) => { @@ -155,8 +164,11 @@ function resolveStyles(sketchDoc, files) { css.getAttribute('href') && 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 | undefined; + if (resolvedFile && typeof resolvedFile === 'object') { if (resolvedFile.url) { css.href = resolvedFile.url; // eslint-disable-line } else { @@ -170,8 +182,8 @@ function resolveStyles(sketchDoc, files) { }); } -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,7 +196,7 @@ 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) => { @@ -192,7 +204,17 @@ function addLoopProtect(sketchDoc) { }); } -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 = []; objectUrls = {}; @@ -253,20 +275,39 @@ p5.prototype.registerMethod('afterSetup', p5.prototype.ensureAccessibleCanvas);` return `\n${sketchDoc.documentElement.outerHTML}`; } -function getHtmlFile(files) { - return files.filter((file) => file.name.match(/.*\.html$/i))[0]; +function getHtmlFile(files: File[]): File { + return files.filter((file: File) => file.name.match(/.*\.html$/i))[0]; +} + +interface EmbedFrameProps { + files: File[]; + isPlaying: boolean; + basePath: string; + gridOutput: boolean; + textOutput: boolean; } -function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { - const iframe = useRef(); +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(() => { + if (!iframe.current?.contentWindow) { + return; + } + const unsubscribe = registerFrame( iframe.current.contentWindow, window.origin ); + // eslint-disable-next-line consistent-return return () => { unsubscribe(); }; @@ -274,32 +315,43 @@ function EmbedFrame({ files, isPlaying, basePath, gridOutput, textOutput }) { function renderSketch() { const doc = iframe.current; - if (isPlaying) { + if (isPlaying && doc) { const htmlDoc = injectLocalFiles(files, htmlFile, { basePath, gridOutput, textOutput }); - const generatedHtmlFile = { + const generatedHtmlFile: File = { + id: '', name: 'index.html', - content: htmlDoc + content: htmlDoc, + children: [] }; const htmlUrl = createBlobUrl(generatedHtmlFile); const toRevoke = srcRef.current; srcRef.current = htmlUrl; // BRO FOR SOME REASON YOU HAVE TO DO THIS TO GET IT TO WORK ON SAFARI setTimeout(() => { - doc.src = htmlUrl; + if (doc) { + doc.src = htmlUrl; + } if (toRevoke) { blobUtil.revokeObjectURL(toRevoke); } }, 0); - } else { + } else if (doc) { doc.src = ''; } } - useEffect(renderSketch, [files, isPlaying]); + useEffect(renderSketch, [ + files, + isPlaying, + htmlFile, + basePath, + gridOutput, + textOutput + ]); return ( {} + +interface Action { + type: string; + files?: File[]; +} + // https://gist.github.com/fnky/7d044b94070a35e552f3c139cdf80213 -export function useSelectors(state, mapStateToSelectors) { +export function useSelectors( + state: T, + mapStateToSelectors: (state: T) => any +) { const selectors = useMemo(() => mapStateToSelectors(state), [state]); return selectors; } -export function getFileSelectors(state) { +export function getFileSelectors(state: State) { return { getHTMLFile: () => state.filter((file) => file.name.match(/.*\.html$/i))[0], getJSFiles: () => state.filter((file) => file.name.match(/.*\.js$/i)), @@ -17,20 +38,20 @@ export function getFileSelectors(state) { }; } -function sortedChildrenId(state, children) { +function sortedChildrenId(state: State, 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[]) { return { type: 'SET_FILES', files }; } -export function createBlobUrl(file) { +export function createBlobUrl(file: File): string { if (file.blobUrl) { blobUtil.revokeObjectURL(file.blobUrl); } @@ -43,7 +64,7 @@ export function createBlobUrl(file) { return blobURL; } -export function createBlobUrls(state) { +export function createBlobUrls(state: State): State { return state.map((file) => { if (file.name.match(PLAINTEXT_FILE_REGEX)) { const blobUrl = createBlobUrl(file); @@ -53,10 +74,10 @@ export function createBlobUrls(state) { }); } -export function filesReducer(state, action) { +export function filesReducer(state: State, action: Action): State { switch (action.type) { case 'SET_FILES': - return createBlobUrls(action.files); + return createBlobUrls(action.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.tsx similarity index 77% rename from client/modules/Preview/previewIndex.jsx rename to client/modules/Preview/previewIndex.tsx index 12c14e3b79..a2d1f7eb5e 100644 --- a/client/modules/Preview/previewIndex.jsx +++ b/client/modules/Preview/previewIndex.tsx @@ -11,6 +11,7 @@ import { filesReducer, setFiles } from './filesReducer'; import EmbedFrame from './EmbedFrame'; import { getConfig } from '../../utils/getConfig'; import { initialState } from '../IDE/reducers/files'; +import { File } from './filesReducer'; // Import File interface const GlobalStyle = createGlobalStyle` body { @@ -18,6 +19,14 @@ const GlobalStyle = createGlobalStyle` } `; +interface AppState { + files: File[]; + isPlaying: boolean; + basePath: string; + textOutput: boolean; + gridOutput: boolean; +} + const App = () => { const [state, dispatch] = useReducer(filesReducer, [], initialState); const [isPlaying, setIsPlaying] = useState(false); @@ -26,14 +35,16 @@ const App = () => { const [gridOutput, setGridOutput] = useState(false); registerFrame(window.parent, getConfig('EDITOR_URL')); - function handleMessageEvent(message) { + function handleMessageEvent(message: { type: string; payload?: any }) { const { type, payload } = message; switch (type) { case MessageTypes.SKETCH: - dispatch(setFiles(payload.files)); - setBasePath(payload.basePath); - setTextOutput(payload.textOutput); - setGridOutput(payload.gridOutput); + if (payload) { + dispatch(setFiles(payload.files)); + setBasePath(payload.basePath); + setTextOutput(payload.textOutput); + setGridOutput(payload.gridOutput); + } break; case MessageTypes.START: setIsPlaying(true); @@ -45,14 +56,16 @@ const App = () => { dispatchMessage({ type: MessageTypes.REGISTER }); break; case MessageTypes.EXECUTE: - dispatchMessage(payload); + if (payload) { + dispatchMessage(payload); + } break; default: break; } } - function addCacheBustingToAssets(files) { + function addCacheBustingToAssets(files: File[]): File[] { const timestamp = new Date().getTime(); return files.map((file) => { if (file.url && !file.url.endsWith('obj') && !file.url.endsWith('stl')) { diff --git a/client/modules/User/components/APIKeyForm.jsx b/client/modules/User/components/APIKeyForm.tsx similarity index 83% rename from client/modules/User/components/APIKeyForm.jsx rename to client/modules/User/components/APIKeyForm.tsx index fda5945f87..cd77994f06 100644 --- a/client/modules/User/components/APIKeyForm.jsx +++ b/client/modules/User/components/APIKeyForm.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -6,31 +5,24 @@ import { Button, ButtonTypes } from '../../../common/Button'; import { PlusIcon } from '../../../common/icons'; import CopyableInput from '../../IDE/components/CopyableInput'; import { createApiKey, removeApiKey } from '../actions'; +import { APIKeyList } from './APIKeyList'; +import { RootState } from '../../../reducers'; +import { SanitisedApiKey } from '../../../../common/types'; -import APIKeyList from './APIKeyList'; - -export const APIKeyPropType = PropTypes.shape({ - id: PropTypes.string.isRequired, - token: PropTypes.string, - label: PropTypes.string.isRequired, - createdAt: PropTypes.string.isRequired, - lastUsedAt: PropTypes.string -}); - -const APIKeyForm = () => { +export const APIKeyForm = () => { const { t } = useTranslation(); - const apiKeys = useSelector((state) => state.user.apiKeys); + const apiKeys = useSelector((state: RootState) => state.user.apiKeys) ?? []; const dispatch = useDispatch(); const [keyLabel, setKeyLabel] = useState(''); - const addKey = (event) => { + const addKey = (event: React.FormEvent) => { event.preventDefault(); dispatch(createApiKey(keyLabel)); setKeyLabel(''); }; - const removeKey = (key) => { + const removeKey = (key: SanitisedApiKey) => { const message = t('APIKeyForm.ConfirmDelete', { key_label: key.label }); @@ -77,7 +69,7 @@ const APIKeyForm = () => {