diff --git a/client/components/Dropdown/TableDropdown.tsx b/client/components/Dropdown/TableDropdown.tsx index e9408dd6b9..f91325e341 100644 --- a/client/components/Dropdown/TableDropdown.tsx +++ b/client/components/Dropdown/TableDropdown.tsx @@ -9,7 +9,7 @@ import { import DownFilledTriangleIcon from '../../images/down-filled-triangle.svg'; import MoreIconSvg from '../../images/more.svg'; -import useIsMobile from '../../modules/IDE/hooks/useIsMobile'; +import { useIsMobile } from '../../modules/IDE/hooks'; const DotsHorizontal = styled(MoreIconSvg)` transform: rotate(90deg); diff --git a/client/custom.d.ts b/client/custom.d.ts index 729f9e03de..26c52f63cc 100644 --- a/client/custom.d.ts +++ b/client/custom.d.ts @@ -1,3 +1,15 @@ +declare module '*.svg?byUrl' { + const url: string; + // eslint-disable-next-line import/no-default-export + export default url; +} + +declare module '*.svg?byContent' { + const content: string; + // eslint-disable-next-line import/no-default-export + export default content; +} + declare module '*.svg' { import * as React from 'react'; diff --git a/client/modules/IDE/components/Console.jsx b/client/modules/IDE/components/Console.jsx index 3494fccdee..d1d7befde9 100644 --- a/client/modules/IDE/components/Console.jsx +++ b/client/modules/IDE/components/Console.jsx @@ -11,10 +11,9 @@ import DownArrowIcon from '../../../images/down-arrow.svg'; import * as IDEActions from '../actions/ide'; import * as ConsoleActions from '../actions/console'; -import { useDidUpdate } from '../hooks/custom-hooks'; -import useHandleMessageEvent from '../hooks/useHandleMessageEvent'; +import { useDidUpdate, useHandleMessageEvent } from '../hooks'; import { listen } from '../../../utils/dispatcher'; -import getConsoleFeedStyle from '../utils/consoleStyles'; +import { getConsoleFeedStyle } from '../utils/consoleStyles'; const Console = () => { const { t } = useTranslation(); diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx index 28995a2d56..12a710a0d4 100644 --- a/client/modules/IDE/components/Header/Nav.jsx +++ b/client/modules/IDE/components/Header/Nav.jsx @@ -30,7 +30,7 @@ import { import { logoutUser } from '../../../User/actions'; import { CmControllerContext } from '../../pages/IDEView'; import MobileNav from './MobileNav'; -import useIsMobile from '../../hooks/useIsMobile'; +import { useIsMobile } from '../../hooks'; const Nav = ({ layout }) => { const isMobile = useIsMobile(); diff --git a/client/modules/IDE/components/Header/Toolbar.unit.test.jsx b/client/modules/IDE/components/Header/Toolbar.unit.test.jsx index 82b4cb7f4c..ef0b7c21a9 100644 --- a/client/modules/IDE/components/Header/Toolbar.unit.test.jsx +++ b/client/modules/IDE/components/Header/Toolbar.unit.test.jsx @@ -11,6 +11,7 @@ import { } from '../../../../test-utils'; import { selectProjectName } from '../../selectors/project'; import ToolbarComponent from './Toolbar'; +import { P5VersionProvider } from '../../hooks/useP5Version'; const server = setupServer( rest.put(`/projects/id`, (req, res, ctx) => res(ctx.json(req.body))) @@ -49,7 +50,12 @@ const renderComponent = (extraState = {}) => { return { ...props, - ...reduxRender(, { initialState }) + ...reduxRender( + + + , + { initialState } + ) }; }; diff --git a/client/modules/IDE/components/Header/index.jsx b/client/modules/IDE/components/Header/index.jsx index 79f78ff67e..871188641f 100644 --- a/client/modules/IDE/components/Header/index.jsx +++ b/client/modules/IDE/components/Header/index.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import useIsMobile from '../../hooks/useIsMobile'; +import { useIsMobile } from '../../hooks'; import Nav from './Nav'; import Toolbar from './Toolbar'; diff --git a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx index 13e4c88881..41391a615b 100644 --- a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx +++ b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx @@ -3,6 +3,7 @@ import { act, fireEvent, reduxRender, screen } from '../../../../test-utils'; import { initialState } from '../../reducers/preferences'; import Preferences from './index'; import * as PreferencesActions from '../../actions/preferences'; +import { P5VersionProvider } from '../../hooks/useP5Version'; describe('', () => { // For backwards compatibility, spy on each action creator to see when it was dispatched. @@ -14,14 +15,19 @@ describe('', () => { ); const subject = (initialPreferences = {}) => - reduxRender(, { - initialState: { - preferences: { - ...initialState, - ...initialPreferences + reduxRender( + + + , + { + initialState: { + preferences: { + ...initialState, + ...initialPreferences + } } } - }); + ); afterEach(() => { jest.clearAllMocks(); diff --git a/client/modules/IDE/components/Timer.jsx b/client/modules/IDE/components/Timer.jsx index 2502e9f33c..4eeeb5be50 100644 --- a/client/modules/IDE/components/Timer.jsx +++ b/client/modules/IDE/components/Timer.jsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { distanceInWordsToNow } from '../../../utils/formatDate'; -import useInterval from '../hooks/useInterval'; +import { useInterval } from '../hooks/useInterval'; import { getIsUserOwner } from '../selectors/users'; const Timer = () => { diff --git a/client/modules/IDE/hooks/custom-hooks.js b/client/modules/IDE/hooks/custom-hooks.js deleted file mode 100644 index 6471bbcac2..0000000000 --- a/client/modules/IDE/hooks/custom-hooks.js +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -export const noop = () => {}; - -export const useDidUpdate = (callback, deps) => { - const hasMount = useRef(false); - - useEffect(() => { - if (hasMount.current) { - callback(); - } else { - hasMount.current = true; - } - }, deps); -}; - -// Usage: const ref = useModalBehavior(() => setSomeState(false)) -// place this ref on a component -export const useModalBehavior = (hideOverlay) => { - const ref = useRef({}); - - // Return values - const setRef = (r) => { - ref.current = r; - }; - const [visible, setVisible] = useState(false); - const trigger = () => setVisible(!visible); - - const hide = () => setVisible(false); - - const handleClickOutside = ({ target }) => { - if ( - ref && - ref.current && - !(ref.current.contains && ref.current.contains(target)) - ) { - hide(); - } - }; - - useEffect(() => { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, [ref]); - - return [visible, trigger, setRef]; -}; - -// Usage: useEffectWithComparison((props, prevProps) => { ... }, { prop1, prop2 }) -// This hook basically applies useEffect but keeps track of the last value of relevant props -// So you can pass a 2-param function to capture new and old values and do whatever with them. -export const useEffectWithComparison = (fn, props) => { - const [prevProps, update] = useState({}); - - return useEffect(() => { - fn(props, prevProps); - update(props); - }, Object.values(props)); -}; - -export const useEventListener = ( - event, - callback, - useCapture = false, - list = [] -) => - useEffect(() => { - document.addEventListener(event, callback, useCapture); - return () => document.removeEventListener(event, callback, useCapture); - }, list); diff --git a/client/modules/IDE/hooks/index.js b/client/modules/IDE/hooks/index.js deleted file mode 100644 index d309cd4b88..0000000000 --- a/client/modules/IDE/hooks/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { default as useSketchActions } from './useSketchActions'; -export { default as useWhatPage } from './useWhatPage'; diff --git a/client/modules/IDE/hooks/index.ts b/client/modules/IDE/hooks/index.ts new file mode 100644 index 0000000000..fd3a9b3f52 --- /dev/null +++ b/client/modules/IDE/hooks/index.ts @@ -0,0 +1,6 @@ +export * from './useSketchActions'; +export * from './useWhatPage'; +export * from './useIsMobile'; +export * from './useDidUpdate'; +export * from './useInterval'; +export * from './useHandleMessageEvent'; diff --git a/client/modules/IDE/hooks/useDidUpdate.ts b/client/modules/IDE/hooks/useDidUpdate.ts new file mode 100644 index 0000000000..4d04f1065b --- /dev/null +++ b/client/modules/IDE/hooks/useDidUpdate.ts @@ -0,0 +1,16 @@ +import React, { useEffect, useRef } from 'react'; + +export const useDidUpdate = ( + callback: () => void, + deps: React.DependencyList = [] +) => { + const hasMount = useRef(false); + + useEffect(() => { + if (hasMount.current) { + callback(); + } else { + hasMount.current = true; + } + }, deps); +}; diff --git a/client/modules/IDE/hooks/useHandleMessageEvent.js b/client/modules/IDE/hooks/useHandleMessageEvent.ts similarity index 69% rename from client/modules/IDE/hooks/useHandleMessageEvent.js rename to client/modules/IDE/hooks/useHandleMessageEvent.ts index 5603c1d697..db39665f5a 100644 --- a/client/modules/IDE/hooks/useHandleMessageEvent.js +++ b/client/modules/IDE/hooks/useHandleMessageEvent.ts @@ -1,18 +1,25 @@ import { useDispatch } from 'react-redux'; import { Decode } from 'console-feed'; +import { Message } from 'console-feed/lib/definitions/Console'; import { dispatchConsoleEvent } from '../actions/console'; import { stopSketch, expandConsole } from '../actions/ide'; -export default function useHandleMessageEvent() { +type SafeValue = string | number | boolean | null | SafeObject | SafeArray; +interface SafeObject { + [key: string]: SafeValue; +} +interface SafeArray extends Array {} + +export function useHandleMessageEvent() { const dispatch = useDispatch(); const safeStringify = ( - obj, + obj: unknown, depth = 0, maxDepth = 10, - seen = new WeakMap() - ) => { - if (typeof obj !== 'object' || obj === null) return obj; + seen = new WeakMap() + ): SafeValue => { + if (typeof obj !== 'object' || obj === null) return obj as SafeValue; if (depth >= maxDepth) { if (seen.has(obj)) return '[Circular Reference]'; @@ -30,9 +37,13 @@ export default function useHandleMessageEvent() { ); }; - const handleMessageEvent = (data) => { + const handleMessageEvent = (data: Message['data']) => { if (!data || typeof data !== 'object') return; - const { source, messages } = data; + + const { source, messages } = data as { + source?: string; + messages?: { log?: unknown }[]; + }; if (source !== 'sketch' || !Array.isArray(messages)) return; const decodedMessages = messages.map((message) => { @@ -48,8 +59,8 @@ export default function useHandleMessageEvent() { // Detect infinite loop warnings const hasInfiniteLoop = decodedMessages.some( (message) => - message?.data && - Object.values(message.data).some( + (message as SafeObject)?.data && + Object.values((message as SafeObject)?.data as SafeObject).some( (arg) => typeof arg === 'string' && arg.includes('Exiting potential infinite loop') diff --git a/client/modules/IDE/hooks/useInterval.js b/client/modules/IDE/hooks/useInterval.ts similarity index 61% rename from client/modules/IDE/hooks/useInterval.js rename to client/modules/IDE/hooks/useInterval.ts index 63ddaf5503..b6a2ed5a66 100644 --- a/client/modules/IDE/hooks/useInterval.js +++ b/client/modules/IDE/hooks/useInterval.ts @@ -1,9 +1,11 @@ // https://overreacted.io/making-setinterval-declarative-with-react-hooks/ import { useState, useEffect, useRef } from 'react'; -export default function useInterval(callback, delay) { - const savedCallback = useRef(); - const [intervalId, setIntervalId] = useState(); +export function useInterval(callback: () => void, delay: number) { + const savedCallback = useRef<() => void>(); + const [intervalId, setIntervalId] = useState< + ReturnType + >(); // Remember the latest callback. useEffect(() => { @@ -11,16 +13,18 @@ export default function useInterval(callback, delay) { }, [callback]); // Set up the interval. + // eslint-disable-next-line consistent-return useEffect(() => { function tick() { - savedCallback.current(); + if (savedCallback.current) { + savedCallback.current(); + } } if (delay !== null) { const id = setInterval(tick, delay); setIntervalId(id); return () => clearInterval(id); } - return null; }, [delay]); return () => clearInterval(intervalId); } diff --git a/client/modules/IDE/hooks/useIsMobile.js b/client/modules/IDE/hooks/useIsMobile.ts similarity index 71% rename from client/modules/IDE/hooks/useIsMobile.js rename to client/modules/IDE/hooks/useIsMobile.ts index c278dcbd8a..1dbec11a42 100644 --- a/client/modules/IDE/hooks/useIsMobile.js +++ b/client/modules/IDE/hooks/useIsMobile.ts @@ -1,9 +1,7 @@ import { useMediaQuery } from 'react-responsive'; -const useIsMobile = (customBreakpoint) => { +export const useIsMobile = (customBreakpoint?: number): boolean => { const breakPoint = customBreakpoint || 770; const isMobile = useMediaQuery({ maxWidth: breakPoint }); return isMobile; }; - -export default useIsMobile; diff --git a/client/modules/IDE/hooks/useP5Version.jsx b/client/modules/IDE/hooks/useP5Version.tsx similarity index 65% rename from client/modules/IDE/hooks/useP5Version.jsx rename to client/modules/IDE/hooks/useP5Version.tsx index a21a7c3b7f..84c7fddf4e 100644 --- a/client/modules/IDE/hooks/useP5Version.jsx +++ b/client/modules/IDE/hooks/useP5Version.tsx @@ -1,7 +1,6 @@ /* eslint-disable func-names */ import React, { useContext, useMemo } from 'react'; import { useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; import { currentP5Version, p5Versions } from '../../../../common/p5Versions'; import { p5SoundURLOldTemplate, @@ -11,8 +10,9 @@ import { p5DataAddonURL, p5URLTemplate } from '../../../../common/p5URLs'; +import type { RootState } from '../../../reducers'; -export const majorVersion = (version) => version.split('.')[0]; +export const majorVersion = (version: string) => version.split('.')[0]; export const p5SoundURLOld = p5SoundURLOldTemplate.replace( '$VERSION', @@ -20,12 +20,31 @@ export const p5SoundURLOld = p5SoundURLOldTemplate.replace( ); export const p5URL = p5URLTemplate.replace('$VERSION', currentP5Version); -const P5VersionContext = React.createContext({}); - -export function P5VersionProvider(props) { - const files = useSelector((state) => state.files); +const P5VersionContext = React.createContext<{ + indexID: string; + versionInfo: { + version: string; + isVersion2: boolean; + minified: boolean; + replaceVersion: (newVersion: string) => void; + p5Sound: boolean; + setP5Sound: (enabled: boolean) => string; + setP5SoundURL: (url: string) => string; + p5SoundURL: string | null; + p5PreloadAddon: boolean; + setP5PreloadAddon: (enabled: boolean) => string; + p5ShapesAddon: boolean; + setP5ShapesAddon: (enabled: boolean) => string; + p5DataAddon: boolean; + setP5DataAddon: (enabled: boolean) => string; + } | null; +} | null>(null); + +export function P5VersionProvider(props: { children: React.ReactNode }) { + const files = useSelector((state: RootState) => state.files); const indexFile = files.find( - (file) => + // TODO: clairepeng94 - update this to Project > File type once backend migration is complete + (file: { fileType: string; name: string; filePath: string }) => file.fileType === 'file' && file.name === 'index.html' && file.filePath === '' @@ -33,7 +52,6 @@ export function P5VersionProvider(props) { const indexSrc = indexFile?.content; const indexID = indexFile?.id; - // { version: string, minified: boolean, replaceVersion: (version: string) => string } | null const versionInfo = useMemo(() => { if (!indexSrc) return null; const dom = new DOMParser().parseFromString(indexSrc, 'text/html'); @@ -49,7 +67,9 @@ export function P5VersionProvider(props) { return src; }; - const usedP5Versions = [...dom.documentElement.querySelectorAll('script')] + const scriptNodes = [...dom.documentElement.querySelectorAll('script')]; + + const usedP5Versions = scriptNodes .map((scriptNode) => { const src = scriptNode.getAttribute('src') || ''; const matches = [ @@ -74,18 +94,17 @@ export function P5VersionProvider(props) { if (usedP5Versions.length === 1) { const { version, minified, scriptNode } = usedP5Versions[0]; - const p5SoundNode = [ - ...dom.documentElement.querySelectorAll('script') - ].find((s) => + const p5SoundNode = scriptNodes.find((s) => [ /^https?:\/\/cdnjs.cloudflare.com\/ajax\/libs\/p5.js\/(.+)\/addons\/p5\.sound(?:\.min)?\.js$/, /^https?:\/\/cdn.jsdelivr.net\/npm\/p5@(.+)\/lib\/addons\/p5\.sound(?:\.min)?\.js$/, /^https?:\/\/cdn.jsdelivr.net\/npm\/p5.sound@(.+)\/dist\/p5\.sound(?:\.min)?\.js$/ ].some((regex) => regex.exec(s.getAttribute('src') || '')) ); - const setP5Sound = function (enabled) { + + const setP5Sound = function (enabled: boolean) { if (!enabled && p5SoundNode) { - p5SoundNode.parentNode.removeChild(p5SoundNode); + p5SoundNode.parentNode?.removeChild(p5SoundNode); } else if (enabled && !p5SoundNode) { const newNode = document.createElement('script'); newNode.setAttribute( @@ -94,23 +113,23 @@ export function P5VersionProvider(props) { ? p5SoundURL : p5SoundURLOldTemplate.replace('$VERSION', version) ); - scriptNode.parentNode.insertBefore(newNode, scriptNode.nextSibling); + scriptNode.parentNode?.insertBefore(newNode, scriptNode.nextSibling); } return serializeResult(); }; - const setP5SoundURL = function (url) { + const setP5SoundURL = function (url: string) { if (p5SoundNode) { p5SoundNode.setAttribute('src', url); } else { const newNode = document.createElement('script'); newNode.setAttribute('src', url); - scriptNode.parentNode.insertBefore(newNode, scriptNode.nextSibling); + scriptNode.parentNode?.insertBefore(newNode, scriptNode.nextSibling); } return serializeResult(); }; - const replaceVersion = function (newVersion) { + const replaceVersion = function (newVersion: string) { const file = minified ? 'p5.min.js' : 'p5.js'; scriptNode.setAttribute( 'src', @@ -135,44 +154,47 @@ export function P5VersionProvider(props) { return serializeResult(); }; - const p5PreloadAddonNode = [ - ...dom.documentElement.querySelectorAll('script') - ].find((s) => s.getAttribute('src') === p5PreloadAddonURL); - const setP5PreloadAddon = function (enabled) { + const p5PreloadAddonNode = scriptNodes.find( + (s) => s.getAttribute('src') === p5PreloadAddonURL + ); + + const setP5PreloadAddon = function (enabled: boolean) { if (!enabled && p5PreloadAddonNode) { - p5PreloadAddonNode.parentNode.removeChild(p5PreloadAddonNode); + p5PreloadAddonNode.parentNode?.removeChild(p5PreloadAddonNode); } else if (enabled && !p5PreloadAddonNode) { const newNode = document.createElement('script'); newNode.setAttribute('src', p5PreloadAddonURL); - scriptNode.parentNode.insertBefore(newNode, scriptNode.nextSibling); + scriptNode.parentNode?.insertBefore(newNode, scriptNode.nextSibling); } return serializeResult(); }; - const p5ShapesAddonNode = [ - ...dom.documentElement.querySelectorAll('script') - ].find((s) => s.getAttribute('src') === p5ShapesAddonURL); - const setP5ShapesAddon = function (enabled) { + const p5ShapesAddonNode = scriptNodes.find( + (s) => s.getAttribute('src') === p5ShapesAddonURL + ); + + const setP5ShapesAddon = function (enabled: boolean) { if (!enabled && p5ShapesAddonNode) { - p5ShapesAddonNode.parentNode.removeChild(p5ShapesAddonNode); + p5ShapesAddonNode.parentNode?.removeChild(p5ShapesAddonNode); } else if (enabled && !p5ShapesAddonNode) { const newNode = document.createElement('script'); newNode.setAttribute('src', p5ShapesAddonURL); - scriptNode.parentNode.insertBefore(newNode, scriptNode.nextSibling); + scriptNode.parentNode?.insertBefore(newNode, scriptNode.nextSibling); } return serializeResult(); }; - const p5DataAddonNode = [ - ...dom.documentElement.querySelectorAll('script') - ].find((s) => s.getAttribute('src') === p5DataAddonURL); - const setP5DataAddon = function (enabled) { + const p5DataAddonNode = scriptNodes.find( + (s) => s.getAttribute('src') === p5DataAddonURL + ); + + const setP5DataAddon = function (enabled: boolean) { if (!enabled && p5DataAddonNode) { - p5DataAddonNode.parentNode.removeChild(p5DataAddonNode); + p5DataAddonNode.parentNode?.removeChild(p5DataAddonNode); } else if (enabled && !p5DataAddonNode) { const newNode = document.createElement('script'); newNode.setAttribute('src', p5DataAddonURL); - scriptNode.parentNode.insertBefore(newNode, scriptNode.nextSibling); + scriptNode.parentNode?.insertBefore(newNode, scriptNode.nextSibling); } return serializeResult(); }; @@ -185,7 +207,7 @@ export function P5VersionProvider(props) { p5Sound: !!p5SoundNode, setP5Sound, setP5SoundURL, - p5SoundURL: p5SoundNode?.getAttribute('src'), + p5SoundURL: p5SoundNode?.getAttribute('src') ?? null, p5PreloadAddon: !!p5PreloadAddonNode, setP5PreloadAddon, p5ShapesAddon: !!p5ShapesAddonNode, @@ -207,10 +229,12 @@ export function P5VersionProvider(props) { ); } -P5VersionProvider.propTypes = { - children: PropTypes.node.isRequired -}; - export function useP5Version() { - return useContext(P5VersionContext); + const context = useContext(P5VersionContext); + + if (!context) { + throw new Error('useP5Version must be used within a P5VersionProvider'); + } + + return context; } diff --git a/client/modules/IDE/hooks/useSketchActions.js b/client/modules/IDE/hooks/useSketchActions.js deleted file mode 100644 index a4c5d70235..0000000000 --- a/client/modules/IDE/hooks/useSketchActions.js +++ /dev/null @@ -1,73 +0,0 @@ -import { useDispatch, useSelector } from 'react-redux'; -import { useTranslation } from 'react-i18next'; -import { useParams } from 'react-router'; -import { - autosaveProject, - exportProjectAsZip, - newProject, - saveProject, - setProjectName -} from '../actions/project'; -import { showToast } from '../actions/toast'; -import { showErrorModal, showShareModal } from '../actions/ide'; -import { selectCanEditSketch } from '../selectors/users'; - -const useSketchActions = () => { - const unsavedChanges = useSelector((state) => state.ide.unsavedChanges); - const authenticated = useSelector((state) => state.user.authenticated); - const project = useSelector((state) => state.project); - const user = useSelector((state) => state.user); - const canEditProjectName = useSelector(selectCanEditSketch); - const dispatch = useDispatch(); - const { t } = useTranslation(); - const params = useParams(); - - function newSketch() { - if (!unsavedChanges) { - dispatch(showToast('Toast.OpenedNewSketch')); - dispatch(newProject()); - } else if (window.confirm(t('Nav.WarningUnsavedChanges'))) { - dispatch(showToast('Toast.OpenedNewSketch')); - dispatch(newProject()); - } - } - - function saveSketch(cmController) { - if (authenticated) { - dispatch(saveProject(cmController?.getContent())); - } else { - dispatch(showErrorModal('forceAuthentication')); - } - } - - function downloadSketch() { - if (authenticated && user.id === project.owner.id) { - dispatch(autosaveProject()); - exportProjectAsZip(project.id); - } - } - - function shareSketch() { - const { username } = params; - dispatch(showShareModal(project.id, project.name, username)); - } - - function changeSketchName(name) { - const newProjectName = name.trim(); - if (newProjectName.length > 0) { - dispatch(setProjectName(newProjectName)); - if (project.id) dispatch(saveProject()); - } - } - - return { - newSketch, - saveSketch, - downloadSketch, - shareSketch, - changeSketchName, - canEditProjectName - }; -}; - -export default useSketchActions; diff --git a/client/modules/IDE/hooks/useSketchActions.ts b/client/modules/IDE/hooks/useSketchActions.ts new file mode 100644 index 0000000000..9ca7345a69 --- /dev/null +++ b/client/modules/IDE/hooks/useSketchActions.ts @@ -0,0 +1,91 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router'; +import { + autosaveProject, + exportProjectAsZip, + newProject, + saveProject, + setProjectName +} from '../actions/project'; +import { showToast } from '../actions/toast'; +import { showErrorModal, showShareModal } from '../actions/ide'; +import { selectCanEditSketch } from '../selectors/users'; +import type { RootState } from '../../../reducers'; + +export const useSketchActions = () => { + const unsavedChanges = useSelector( + (state: RootState) => state.ide.unsavedChanges + ); + const authenticated = useSelector( + (state: RootState) => state.user.authenticated + ); + const project = useSelector((state: RootState) => state.project); + const user = useSelector((state: RootState) => state.user); + const canEditProjectName = useSelector(selectCanEditSketch); + const dispatch = useDispatch(); + const { t } = useTranslation(); + const params = useParams<{ username: string }>(); + + const newSketch = useCallback(() => { + if (!unsavedChanges) { + dispatch(showToast('Toast.OpenedNewSketch')); + dispatch(newProject()); + } else if (window.confirm(t('Nav.WarningUnsavedChanges'))) { + dispatch(showToast('Toast.OpenedNewSketch')); + dispatch(newProject()); + } + }, [dispatch, showToast, newProject, unsavedChanges]); + + const saveSketch = useCallback( + (cmController: { getContent: () => null | undefined }) => { + if (authenticated) { + dispatch(saveProject(cmController.getContent())); + } else { + dispatch(showErrorModal('forceAuthentication')); + } + }, + [dispatch, saveProject, showErrorModal, authenticated] + ); + + const downloadSketch = useCallback(() => { + if (authenticated && user.id === project.owner.id) { + dispatch(autosaveProject()); + exportProjectAsZip(project.id); + } + }, [ + dispatch, + authenticated, + autosaveProject, + user, + project, + autosaveProject, + exportProjectAsZip + ]); + + const shareSketch = useCallback(() => { + const { username } = params; + dispatch(showShareModal(project.id, project.name, username)); + }, [params, dispatch, showShareModal, project]); + + const changeSketchName = useCallback( + (name: string) => { + const newProjectName = name.trim(); + if (newProjectName.length > 0) { + dispatch(setProjectName(newProjectName)); + if (project.id) dispatch(saveProject()); + } + }, + [dispatch, setProjectName, project.id, saveProject] + ); + + return { + newSketch, + saveSketch, + downloadSketch, + shareSketch, + changeSketchName, + canEditProjectName + }; +}; diff --git a/client/modules/IDE/hooks/useWhatPage.js b/client/modules/IDE/hooks/useWhatPage.ts similarity index 74% rename from client/modules/IDE/hooks/useWhatPage.js rename to client/modules/IDE/hooks/useWhatPage.ts index 8126c596c0..ed6af44031 100644 --- a/client/modules/IDE/hooks/useWhatPage.js +++ b/client/modules/IDE/hooks/useWhatPage.ts @@ -1,13 +1,16 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; +import type { RootState } from '../../../reducers'; -/** - * - * @returns {"home" | "myStuff" | "login" | "signup" | "account" | "examples"} - */ -const useWhatPage = () => { - const username = useSelector((state) => state.user.username); +export const useWhatPage = (): + | 'home' + | 'myStuff' + | 'login' + | 'signup' + | 'account' + | 'examples' => { + const username = useSelector((state: RootState) => state.user.username); const { pathname } = useLocation(); const pageName = useMemo(() => { @@ -26,5 +29,3 @@ const useWhatPage = () => { return pageName; }; - -export default useWhatPage; diff --git a/client/modules/IDE/pages/FullView.jsx b/client/modules/IDE/pages/FullView.jsx index bf2e66fdcb..791ba4b51b 100644 --- a/client/modules/IDE/pages/FullView.jsx +++ b/client/modules/IDE/pages/FullView.jsx @@ -11,7 +11,7 @@ import { dispatchMessage, MessageTypes } from '../../../utils/dispatcher'; -import useInterval from '../hooks/useInterval'; +import { useInterval } from '../hooks'; import { RootPage } from '../../../components/RootPage'; function FullView() { diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index d81930a327..30264b0fed 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -25,7 +25,7 @@ import { PreviewWrapper } from '../components/Editor/MobileEditor'; import IDEOverlays from '../components/IDEOverlays'; -import useIsMobile from '../hooks/useIsMobile'; +import { useIsMobile } from '../hooks'; import Banner from '../components/Banner'; import { P5VersionProvider } from '../hooks/useP5Version'; diff --git a/client/modules/IDE/utils/consoleStyles.js b/client/modules/IDE/utils/consoleStyles.ts similarity index 98% rename from client/modules/IDE/utils/consoleStyles.js rename to client/modules/IDE/utils/consoleStyles.ts index 83d53b49c3..f94125c45b 100644 --- a/client/modules/IDE/utils/consoleStyles.js +++ b/client/modules/IDE/utils/consoleStyles.ts @@ -150,7 +150,7 @@ const CONSOLE_FEED_CONTRAST_ICONS = { LOG_RESULT_ICON: `url(${resultContrastUrl})` }; -const getConsoleFeedStyle = (theme, fontSize) => { +export const getConsoleFeedStyle = (theme: string, fontSize: number) => { const CONSOLE_FEED_SIZES = { TREENODE_LINE_HEIGHT: 1.2, BASE_FONT_SIZE: `${fontSize}px`, @@ -184,5 +184,3 @@ const getConsoleFeedStyle = (theme, fontSize) => { ); } }; - -export default getConsoleFeedStyle; diff --git a/client/modules/User/components/DashboardTabSwitcher.tsx b/client/modules/User/components/DashboardTabSwitcher.tsx index 8ec810cf64..607fbc9bfc 100644 --- a/client/modules/User/components/DashboardTabSwitcher.tsx +++ b/client/modules/User/components/DashboardTabSwitcher.tsx @@ -7,7 +7,7 @@ import { IconButton } from '../../../common/IconButton'; import { RouterTab } from '../../../common/RouterTab'; import { Options } from '../../IDE/components/Header/MobileNav'; import { toggleDirectionForField } from '../../IDE/actions/sorting'; -import useIsMobile from '../../IDE/hooks/useIsMobile'; +import { useIsMobile } from '../../IDE/hooks'; export enum TabKey { assets = 'assets', diff --git a/client/modules/User/pages/DashboardView.jsx b/client/modules/User/pages/DashboardView.jsx index 8044de028a..4870393941 100644 --- a/client/modules/User/pages/DashboardView.jsx +++ b/client/modules/User/pages/DashboardView.jsx @@ -22,7 +22,7 @@ import { DashboardTabSwitcher, TabKey } from '../components/DashboardTabSwitcher'; -import useIsMobile from '../../IDE/hooks/useIsMobile'; +import { useIsMobile } from '../../IDE/hooks'; const DashboardView = () => { const isMobile = useIsMobile(); diff --git a/client/utils/dispatcher.ts b/client/utils/dispatcher.ts index 4966a1620c..95d27ea1cb 100644 --- a/client/utils/dispatcher.ts +++ b/client/utils/dispatcher.ts @@ -1,3 +1,4 @@ +import type { Message as ConsoleMessage } from 'console-feed/lib/definitions/Console'; // Inspired by // https://github.com/codesandbox/codesandbox-client/blob/master/packages/codesandbox-api/src/dispatcher/index.ts @@ -28,7 +29,7 @@ export interface Message { payload?: unknown; } -let listener: ((message: Message) => void) | null = null; +let listener: ((message: Message | ConsoleMessage) => void) | null = null; /** * Registers a frame to receive future dispatched messages. @@ -48,7 +49,7 @@ export function registerFrame( }; } -function notifyListener(message: Message): void { +function notifyListener(message: Message | ConsoleMessage): void { if (listener) listener(message); } @@ -74,7 +75,9 @@ export function dispatchMessage(message: Message | undefined | null): void { /** * Call callback to remove listener */ -export function listen(callback: (message: Message) => void): () => void { +export function listen( + callback: (message: Message | ConsoleMessage) => void +): () => void { listener = callback; return () => { listener = null; diff --git a/package-lock.json b/package-lock.json index 8f13046ae3..58e0a62eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -173,6 +173,7 @@ "@types/react": "^16.14.0", "@types/react-dom": "^16.9.25", "@types/react-helmet": "^6.1.11", + "@types/react-responsive": "^8.0.8", "@types/react-router-dom": "^5.3.3", "@types/react-tabs": "^2.3.1", "@types/react-transition-group": "^4.4.12", @@ -16678,6 +16679,16 @@ "redux": "^4.0.0" } }, + "node_modules/@types/react-responsive": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@types/react-responsive/-/react-responsive-8.0.8.tgz", + "integrity": "sha512-HDUZtoeFRHrShCGaND23HmXAB9evOOTjkghd2wAasLkuorYYitm5A1XLeKkhXKZppcMBxqB/8V4Snl6hRUTA8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-router": { "version": "5.1.20", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", @@ -53311,6 +53322,15 @@ "redux": "^4.0.0" } }, + "@types/react-responsive": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/@types/react-responsive/-/react-responsive-8.0.8.tgz", + "integrity": "sha512-HDUZtoeFRHrShCGaND23HmXAB9evOOTjkghd2wAasLkuorYYitm5A1XLeKkhXKZppcMBxqB/8V4Snl6hRUTA8g==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-router": { "version": "5.1.20", "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", diff --git a/package.json b/package.json index de6b1f40eb..e5c020d8a8 100644 --- a/package.json +++ b/package.json @@ -148,6 +148,7 @@ "@types/react": "^16.14.0", "@types/react-dom": "^16.9.25", "@types/react-helmet": "^6.1.11", + "@types/react-responsive": "^8.0.8", "@types/react-router-dom": "^5.3.3", "@types/react-tabs": "^2.3.1", "@types/react-transition-group": "^4.4.12",