/* global htmlCodeEl, cssCodeEl, jsCodeEl */ import { h, Component } from 'preact'; // import '../service-worker-registration'; import { MainHeader } from './MainHeader.jsx'; import ContentWrap from './ContentWrap.jsx'; import ContentWrapFiles from './ContentWrapFiles.jsx'; import Footer from './Footer.jsx'; import SavedItemPane from './SavedItemPane.jsx'; import AddLibrary from './AddLibrary.jsx'; import Modal from './Modal.jsx'; import Login from './Login.jsx'; import { computeHtml, computeCss, computeJs } from '../computes'; import { log, generateRandomId, semverCompare, saveAsHtml, handleDownloadsPermission, downloadFile, getCompleteHtml, getFilenameFromUrl, prettify, sanitizeSplitSizes } from '../utils'; import { linearizeFiles, assignFilePaths, getFileFromPath, removeFileAtPath, doesFileExistInFolder, importGithubRepo } from '../fileUtils'; import { itemService } from '../itemService'; import '../db'; import { Notifications } from './Notifications'; import Settings from './Settings.jsx'; import { modes, HtmlModes, CssModes, JsModes } from '../codeModes'; import { trackEvent } from '../analytics'; import { deferred } from '../deferred'; import { alertsService } from '../notifications'; import firebase from 'firebase/app'; import 'firebase/auth'; import { Profile } from './Profile'; import { auth } from '../auth'; import { SupportDeveloperModal } from './SupportDeveloperModal'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; import { takeScreenshot } from '../takeScreenshot'; import { AskToImportModal } from './AskToImportModal'; import { Alerts } from './Alerts'; import Portal from './Portal'; import { HelpModal } from './HelpModal'; import { OnboardingModal } from './OnboardingModal'; import { Js13KModal } from './Js13KModal'; import { CreateNewModal } from './CreateNewModal'; import { Icons } from './Icons'; import JSZip from 'jszip'; import { CommandPalette } from './CommandPalette'; import { OPEN_SAVED_CREATIONS_EVENT, SAVE_EVENT, OPEN_SETTINGS_EVENT, NEW_CREATION_EVENT, SHOW_KEYBOARD_SHORTCUTS_EVENT } from '../commands'; import { commandPaletteService } from '../commandPaletteService'; import { I18nProvider } from '@lingui/react'; import { LocalStorageKeys } from '../constants.js'; if (module.hot) { require('preact/debug'); } const UNSAVED_WARNING_COUNT = 15; const version = '5.1.0'; // Read forced settings as query parameters window.forcedSettings = {}; window.codeHtml = ''; window.codeCss = ''; if (location.search) { let match = location.search.replace(/^\?/, '').match(/settings=([^=]*)/); if (match) { match = match[1]; match.split(',').map(pair => { pair = pair.split(':'); if (pair[1] === 'true') pair[1] = true; else if (pair[1] === 'false') pair[1] = false; window.forcedSettings[pair[0]] = pair[1]; }); } const params = new URLSearchParams(location.search); window.codeHtml = params.get('html') || ''; window.codeCss = params.get('css') || ''; } export default class App extends Component { constructor() { super(); this.AUTO_SAVE_INTERVAL = 15000; // 15 seconds this.modalDefaultStates = { isModalOpen: false, isAddLibraryModalOpen: false, isSettingsModalOpen: false, isHelpModalOpen: false, isNotificationsModalOpen: false, isLoginModalOpen: false, isProfileModalOpen: false, isSupportDeveloperModalOpen: false, isKeyboardShortcutsModalOpen: false, isAskToImportModalOpen: false, isOnboardModalOpen: false, isJs13KModalOpen: false, isCreateNewModalOpen: false, isCommandPaletteOpen: false }; this.state = { isSavedItemPaneOpen: false, ...this.modalDefaultStates, prefs: {}, currentItem: { title: '', externalLibs: { js: '', css: '' }, html: window.codeHtml, css: window.codeCss }, catalogs: {} }; this.defaultSettings = { preserveLastCode: true, replaceNewTab: false, htmlMode: 'html', jsMode: 'js', cssMode: 'css', isCodeBlastOn: false, indentWith: 'spaces', indentSize: 2, editorTheme: 'monokai', keymap: 'sublime', fontSize: 16, refreshOnResize: false, autoPreview: true, editorFont: 'FiraCode', editorCustomFont: '', autoSave: true, autoComplete: true, preserveConsoleLogs: true, lightVersion: false, lineWrap: true, infiniteLoopTimeout: 1000, layoutMode: 2, isJs13kModeOn: false, autoCloseTags: true, lang: 'en', isMonacoEditorOn: false, previewDelay: 500 }; this.prefs = {}; firebase.auth().onAuthStateChanged(user => { this.setState({ isLoginModalOpen: false }); if (user) { log('You are -> ', user); alertsService.add('You are now logged in!'); this.setState({ user }); window.user = user; if (!window.localStorage[LocalStorageKeys.ASKED_TO_IMPORT_CREATIONS]) { this.fetchItems(false, true).then(items => { if (!items.length) { return; } this.oldSavedItems = items; this.oldSavedCreationsCount = items.length; this.setState({ isAskToImportModalOpen: true }); trackEvent('ui', 'askToImportModalSeen'); }); } window.db.getUser(user.uid).then(customUser => { if (customUser) { const prefs = { ...this.state.prefs }; Object.assign(prefs, user.settings); this.setState({ prefs }, this.updateSetting); } }); } else { // User is signed out. this.setState({ user: undefined }); delete window.user; } this.updateProfileUi(); }); } componentWillMount() { var lastCode; window.onunload = () => { if (this.detachedWindow) { this.detachedWindow.close(); } }; window.onbeforeunload = event => { if (this.state.unsavedEditCount) { event.preventDefault(); // Chrome requires returnValue to be set. event.returnValue = ''; } }; db.local.get( { layoutMode: 1, code: '' }, result => { this.toggleLayout(result.layoutMode); this.state.prefs.layoutMode = result.layoutMode; if (result.code) { lastCode = result.code; } } ); // Get synced `preserveLastCode` setting to get back last code (or not). db.getSettings(this.defaultSettings).then(result => { if (window.codeHtml || window.codeCss) { log('Load item from query params', lastCode); this.prettifyHandler('html'); this.prettifyHandler('css'); this.setCurrentItem(this.state.currentItem).then(() => { this.refreshEditor(); }); } else if (result.preserveLastCode && lastCode) { this.setState({ unsavedEditCount: 0 }); log('Load last unsaved item', lastCode); this.setCurrentItem(lastCode).then(() => this.refreshEditor()); } else { this.createNewItem(); } Object.assign(this.state.prefs, result); this.setState({ prefs: { ...this.state.prefs } }, this.updateSetting); }); // Check for new version notifications db.getUserLastSeenVersion().then(lastSeenVersion => { // Check if new user if (!lastSeenVersion) { this.setState({ isOnboardModalOpen: true }); if (document.cookie.indexOf('onboarded') === -1) { trackEvent('ui', 'onboardModalSeen', version); document.cookie = 'onboarded=1'; } window.db.setUserLastSeenVersion(version); // set some initial preferences on closing the onboard modal // Old onboarding. //once(document, 'overlaysClosed', function() {}); } // If its an upgrade if ( lastSeenVersion && semverCompare(lastSeenVersion, version) === -1 && !window.localStorage.pledgeModalSeen ) { this.openSupportDeveloperModal(); window.localStorage.pledgeModalSeen = true; } if (!lastSeenVersion || semverCompare(lastSeenVersion, version) === -1) { this.setState({ hasUnseenChangelog: true }); this.hasSeenNotifications = false; } }); } async loadLanguage(lang) { log('🇯🇲 fetching defninition'); const catalog = await import( /* webpackMode: "lazy", webpackChunkName: "i18n-[index]" */ `../locales/${lang}/messages.js` ); this.setState(state => ({ catalogs: { ...state.catalogs, [lang]: catalog.default } })); } incrementUnsavedChanges() { this.setState(prevState => { const newCount = prevState.unsavedEditCount + 1; if ( newCount % UNSAVED_WARNING_COUNT === 0 && newCount >= UNSAVED_WARNING_COUNT ) { window.saveBtn.classList.add('animated'); window.saveBtn.classList.add('wobble'); window.saveBtn.addEventListener('animationend', () => { window.saveBtn.classList.remove('animated'); window.saveBtn.classList.remove('wobble'); }); } return { unsavedEditCount: newCount }; }); } updateProfileUi() { this.setState(prevState => { if (prevState.user) { document.body.classList.add('is-logged-in'); } else { document.body.classList.remove('is-logged-in'); } return null; }); } refreshEditor() { this.toggleLayout( this.state.currentItem.layoutMode || this.state.prefs.layoutMode ); this.updateExternalLibCount(); this.contentWrap.refreshEditor(); } askForUnsavedChanges() { return confirm( 'You have unsaved changes in your current work. Do you want to discard unsaved changes and continue?' ); } // Creates a new item with passed item's contents forkItem(sourceItem) { if (this.state.unsavedEditCount) { var shouldDiscard = this.askForUnsavedChanges(); if (!shouldDiscard) { return; } } const fork = JSON.parse(JSON.stringify(sourceItem)); delete fork.id; fork.title = '(Forked) ' + sourceItem.title; fork.updatedOn = Date.now(); this.setCurrentItem(fork).then(() => this.refreshEditor()); alertsService.add(`"${sourceItem.title}" was forked`); trackEvent('fn', 'itemForked'); } createNewItem(isFileMode = false, files) { const d = new Date(); let item = { title: 'Untitled ' + d.getDate() + '-' + (d.getMonth() + 1) + '-' + d.getHours() + ':' + d.getMinutes(), createdOn: +d, content: '' }; if (isFileMode) { item = { ...item, files: assignFilePaths( files || [ { name: 'index.html', content: ` Webmaker untitled 1 Hello World ` }, { name: 'styles', isFolder: true, children: [{ name: 'style.css', content: '' }] }, { name: 'script.js', content: '' } ] ) }; } else { item = { ...item, html: '', css: '', js: '', externalLibs: { js: '', css: '' }, layoutMode: this.state.currentLayoutMode }; } this.setCurrentItem(item).then(() => this.refreshEditor()); alertsService.add('New item created'); } openItem(item) { this.setCurrentItem(item).then(() => this.refreshEditor()); alertsService.add('Saved item loaded'); } removeItem(item) { var answer = confirm(`Are you sure you want to delete "${item.title}"?`); if (!answer) { return; } // Remove from items list itemService.unsetItemForUser(item.id); // Remove individual item too. itemService.removeItem(item.id).then(() => { alertsService.add('Item removed.', item); // This item is open in the editor. Lets open a new one. if (this.state.currentItem.id === item.id) { this.createNewItem(); } }); // Remove from cached list delete this.state.savedItems[item.id]; this.setState({ savedItems: { ...this.state.savedItems } }); trackEvent('fn', 'itemRemoved'); } setCurrentItem(item) { const d = deferred(); // TODO: remove later if (!item.files) { item.htmlMode = item.htmlMode || this.state.prefs.htmlMode || HtmlModes.HTML; item.cssMode = item.cssMode || this.state.prefs.cssMode || CssModes.CSS; item.jsMode = item.jsMode || this.state.prefs.jsMode || JsModes.JS; } this.setState({ currentItem: item }, () => { d.resolve(); // savecode will try to get split sizes from DOM, and DOM // will update after sometime. so delay the saving setTimeout(() => { // Save locally so that something which is simply opened (or created newly) and closed without save can opened the next time this.saveCode('code'); }, 2000); }); // Reset auto-saving flag this.isAutoSavingEnabled = false; // Reset unsaved count, in UI also. this.setState({ unsavedEditCount: 0 }); return d.promise; } saveBtnClickHandler() { trackEvent( 'ui', 'saveBtnClick', this.state.currentItem.id ? 'saved' : 'new' ); this.saveItem(); } populateItemsInSavedPane(items) { // TODO: sort desc. by updation date // this.setState({ // savedItems: { ...this.state.savedItems } // }); this.toggleSavedItemsPane(); // HACK: Set overflow after sometime so that the items can animate without getting cropped. // setTimeout(() => $('#js-saved-items-wrap').style.overflowY = 'auto', 1000); } toggleSavedItemsPane(shouldOpen) { this.setState(prevState => { const isSavedItemPaneOpen = shouldOpen === undefined ? !prevState.isSavedItemPaneOpen : shouldOpen; document.body.classList[isSavedItemPaneOpen ? 'add' : 'remove']( 'overlay-visible' ); return { isSavedItemPaneOpen }; }); } /** * Fetches all items from storage * @param {boolean} shouldSaveGlobally Whether to store the fetched items in global arr for later use. * @param {boolean} shouldFetchLocally Intentionally get local items. Used when importing local items to account. * @return {promise} Promise. */ async fetchItems(shouldSaveGlobally, shouldFetchLocally) { var items = []; const savedItems = {}; items = await itemService.getAllItems(shouldFetchLocally); trackEvent('fn', 'fetchItems', items.length); if (shouldSaveGlobally) { items.forEach(item => { savedItems[item.id] = item; }); this.setState({ savedItems }); } return items; } openSavedItemsPane() { this.setState({ isFetchingItems: true }); this.fetchItems(true).then(items => { this.setState({ isFetchingItems: false }); this.populateItemsInSavedPane(items); }); } openAddLibrary() { this.setState({ isAddLibraryModalOpen: true }); } closeSavedItemsPane() { this.setState({ isSavedItemPaneOpen: false }); document.body.classList.remove('overlay-visible'); if (this.editorWithFocus) { this.editorWithFocus.focus(); } } openSettings() { this.setState({ isSettingsModalOpen: true }); } openKeyboardShortcuts() { this.setState({ isKeyboardShortcutsModalOpen: true }); } componentDidMount() { function setBodySize() { document.body.style.height = `${window.innerHeight}px`; } window.addEventListener('resize', () => { setBodySize(); }); // Editor keyboard shortucuts window.addEventListener('keydown', event => { // TODO: refactor common listener code // Ctrl/⌘ + S if ((event.ctrlKey || event.metaKey) && event.keyCode === 83) { event.preventDefault(); this.saveItem(); trackEvent('ui', 'saveItemKeyboardShortcut'); } // Ctrl/⌘ + Shift + 5 if ( (event.ctrlKey || event.metaKey) && event.shiftKey && event.keyCode === 53 ) { event.preventDefault(); this.contentWrap.setPreviewContent(true, true); trackEvent('ui', 'previewKeyboardShortcut'); } else if ((event.ctrlKey || event.metaKey) && event.keyCode === 79) { // Ctrl/⌘ + O event.preventDefault(); this.openSavedItemsPane(); trackEvent('ui', 'openCreationKeyboardShortcut'); } else if ( (event.ctrlKey || event.metaKey) && event.shiftKey && event.keyCode === 191 ) { // Ctrl/⌘ + Shift + ? event.preventDefault(); this.setState({ isKeyboardShortcutsModalOpen: !this.state.isKeyboardShortcutsModalOpen }); trackEvent('ui', 'showKeyboardShortcutsShortcut'); } else if ( event.keyCode === 27 && (event.target.tagName !== 'INPUT' || event.target.id === 'searchInput') ) { // ESCAPE // TODO: whats written next doesn't make sense. Review it. // We might be listening on keydown for some input inside the app, UNLESS its // the search input in saved items pane. In that case // we don't want this to trigger which in turn focuses back the last editor. this.closeSavedItemsPane(); } else if ((event.ctrlKey || event.metaKey) && event.keyCode === 80) { // cmd+shift+P and this is Firefox, do nothing if (event.shiftKey && navigator.userAgent.match(/Firefox/)) { return true; } this.setState({ isCommandPaletteOpen: true, isCommandPaletteInCommandMode: !!event.shiftKey }); trackEvent( 'ui', 'openCommandPaletteKeyboardShortcut', !!event.shiftKey ? 'command' : 'files' ); event.preventDefault(); } else if (event.key === 'F1' && navigator.userAgent.match(/Firefox/)) { // On firefox, open command palette with F1 because Cmd+Shift+P // is for opening private window this.setState({ isCommandPaletteOpen: true, isCommandPaletteInCommandMode: true }); trackEvent('ui', 'openCommandPaletteKeyboardShortcut', 'command'); event.preventDefault(); } }); // Basic Focus trapping window.addEventListener('focusin', e => { if (document.body.classList.contains('overlay-visible')) { const modal = $('.is-modal-visible'); if (!modal) { return; } if (!modal.contains(e.target)) { e.preventDefault(); modal.querySelector('.js-modal__close-btn').focus(); } } }); const commandPalleteHooks = { [NEW_CREATION_EVENT]: () => { this.openNewCreationModal(); }, [OPEN_SAVED_CREATIONS_EVENT]: () => { this.openSavedItemsPane(); }, [SAVE_EVENT]: () => { this.saveItem(); }, [OPEN_SETTINGS_EVENT]: () => { this.openSettings(); }, [SHOW_KEYBOARD_SHORTCUTS_EVENT]: () => { this.openKeyboardShortcuts(); } }; for (let eventName in commandPalleteHooks) { commandPaletteService.subscribe( eventName, commandPalleteHooks[eventName] ); } } shouldComponentUpdate(nextProps, nextState) { const { catalogs } = nextState; const { lang } = nextState.prefs; if (lang && lang !== 'en' && !catalogs[lang]) { this.loadLanguage(lang); } return true; } closeAllOverlays() { if (this.state.isSavedItemPaneOpen) { this.closeSavedItemsPane(); } this.setState({ ...this.modalDefaultStates }); } onExternalLibChange(newValues) { log('onExternalLibChange'); this.state.currentItem.externalLibs = { js: newValues.js, css: newValues.css }; this.updateExternalLibCount(); this.setState({ currentItem: { ...this.state.currentItem } }); this.contentWrap.setPreviewContent(true); alertsService.add('Libraries updated.'); } updateExternalLibCount() { // Calculate no. of external libs var noOfExternalLibs = 0; if (!this.state.currentItem.externalLibs) { return; } noOfExternalLibs += this.state.currentItem.externalLibs.js .split('\n') .filter(lib => !!lib).length; noOfExternalLibs += this.state.currentItem.externalLibs.css .split('\n') .filter(lib => !!lib).length; this.setState({ externalLibCount: noOfExternalLibs }); } toggleLayout(mode) { /* eslint-disable no-param-reassign */ mode = window.innerWidth < 600 ? 2 : mode; if (this.state.currentLayoutMode === mode) { this.contentWrap.resetSplitting(); // mainSplitInstance.setSizes(getMainSplitSizesToApply()); // codeSplitInstance.setSizes(currentItem.sizes || [33.33, 33.33, 33.33]); this.setState({ currentLayoutMode: mode }); return; } // Remove all layout classes [1, 2, 3, 4, 5].forEach(layoutNumber => { window[`layoutBtn${layoutNumber}`].classList.remove('selected'); document.body.classList.remove(`layout-${layoutNumber}`); }); $('#layoutBtn' + mode).classList.add('selected'); document.body.classList.add('layout-' + mode); this.setState({ currentLayoutMode: mode }, () => { this.contentWrap.resetSplitting(); this.contentWrap.setPreviewContent(true); }); } layoutBtnClickHandler(layoutId) { if (layoutId === this.state.currentLayoutMode) { layoutId = 2; } this.saveSetting('layoutMode', layoutId); trackEvent('ui', 'toggleLayoutClick', layoutId); this.toggleLayout(layoutId); } // Calculates the sizes of html, css & js code panes. getCodePaneSizes() { var sizes; const currentLayoutMode = this.state.currentLayoutMode; var dimensionProperty = currentLayoutMode === 2 || currentLayoutMode === 5 ? 'width' : 'height'; try { sizes = [ htmlCodeEl.style[dimensionProperty], cssCodeEl.style[dimensionProperty], jsCodeEl.style[dimensionProperty] ]; } catch (e) { sizes = [33.33, 33.33, 33.33]; } finally { /* eslint-disable no-unsafe-finally */ return sanitizeSplitSizes(sizes); /* eslint-enable no-unsafe-finally */ } } // Calculates the current sizes of code & preview panes. getMainPaneSizes() { let sizes; function getPercentFromDimension(el, dimension = 'width') { const match = el.style[dimension].match(/[\d.]+(%|px)/); if (match) { return match[0]; } return null; } // File mode if (this.state.currentItem && this.state.currentItem.files) { const sidebarWidth = 200; sizes = [ getPercentFromDimension($('#js-sidebar')), getPercentFromDimension($('#js-code-side')), getPercentFromDimension($('#js-demo-side')) ]; // Check if anything was returned falsy, reset in that case if (sizes.filter(s => s).length !== 3) { sizes = [ `${sidebarWidth}px`, `calc(50% - ${sidebarWidth / 2}px)`, `calc(50% - ${sidebarWidth / 2}px)` ]; } return sanitizeSplitSizes(sizes); } const currentLayoutMode = this.state.currentLayoutMode; var dimensionProperty = currentLayoutMode === 2 ? 'height' : 'width'; try { sizes = [ getPercentFromDimension($('#js-code-side'), dimensionProperty), getPercentFromDimension($('#js-demo-side'), dimensionProperty) ]; if (sizes.filter(s => s).length !== 2) { sizes = [50, 50]; } } catch (e) { sizes = [50, 50]; } finally { /* eslint-disable no-unsafe-finally */ return sanitizeSplitSizes(sizes); /* eslint-enable no-unsafe-finally */ } } saveSetting(setting, value) { const d = deferred(); const obj = { [setting]: value }; db.local.set(obj, d.resolve); return d.promise; } saveCode(key) { const { currentItem } = this.state; currentItem.updatedOn = Date.now(); currentItem.layoutMode = this.state.currentLayoutMode; currentItem.mainSizes = this.getMainPaneSizes(); if (!currentItem.files) { currentItem.sizes = this.getCodePaneSizes(); } log('saving key', key || currentItem.id, currentItem); function onSaveComplete() { // No feedback on saving `code` key. Its just to silently preserve // last written code. if (key === 'code') { return; } if (window.user && !navigator.onLine) { alertsService.add( 'Item saved locally. Will save to account when you are online.' ); } else { alertsService.add('Item saved.'); } this.setState({ unsavedEditCount: 0 }); } return itemService .setItem(key || currentItem.id, currentItem) .then(onSaveComplete.bind(this)); } // Save current item to storage saveItem() { if ( !window.user && !window.localStorage[LocalStorageKeys.LOGIN_AND_SAVE_MESSAGE_SEEN] ) { const answer = confirm( 'Saving without signing in will save your work only on this machine and this browser. If you want it to be secure & available anywhere, please login in your account and then save.\n\nDo you still want to continue saving locally?' ); window.localStorage[LocalStorageKeys.LOGIN_AND_SAVE_MESSAGE_SEEN] = true; if (!answer) { trackEvent('ui', LocalStorageKeys.LOGIN_AND_SAVE_MESSAGE_SEEN, 'login'); this.closeAllOverlays(); this.setState({ isLoginModalOpen: true }); return; } trackEvent('ui', LocalStorageKeys.LOGIN_AND_SAVE_MESSAGE_SEEN, 'local'); } var isNewItem = !this.state.currentItem.id; this.state.currentItem.id = this.state.currentItem.id || 'item-' + generateRandomId(); this.setState({ isSaving: true }); this.saveCode().then(() => { this.setState({ isSaving: false }); // TODO: May be setState with currentItem // If this is the first save, and auto-saving settings is enabled, // then start auto-saving from now on. // This is done in `saveCode()` completion so that the // auto-save notification overrides the `saveCode` function's notification. if (!this.isAutoSavingEnabled && this.state.prefs.autoSave) { this.isAutoSavingEnabled = true; alertsService.add('Auto-save enabled.'); } }); // Push into the items hash if its a new item being saved if (isNewItem) { itemService.setItemForUser(this.state.currentItem.id); } } onCodeModeChange(ofWhat, mode) { const item = { ...this.state.currentItem }; item[`${ofWhat}Mode`] = mode; this.setState({ currentItem: item }); } onCodeChange(type, code, isUserChange) { if (this.state.currentItem.files) { linearizeFiles(this.state.currentItem.files).map(file => { if (file.path === type.path) { file.content = code; } }); } else { this.state.currentItem[type] = code; } if (isUserChange) { this.incrementUnsavedChanges(); } if (this.state.prefs.isJs13kModeOn) { // Throttling codesize calculation if (this.codeSizeCalculationTimeout) { clearTimeout(this.codeSizeCalculationTimeout); } this.codeSizeCalculationTimeout = setTimeout(() => { this.calculateCodeSize(); this.codeSizeCalculationTimeout = null; }, 1000); } } onCodeSettingsChange(type, settings) { this.state.currentItem[`${type}Settings`] = { acssConfig: settings }; } titleInputBlurHandler(e) { this.setState({ currentItem: { ...this.state.currentItem, title: e.target.value } }); if (this.state.currentItem.id) { this.saveItem(); trackEvent('ui', 'titleChanged'); } } /** * Handles all user triggered preference changes in the UI. */ updateSetting(settingName, value) { const prefs = { ...this.state.prefs }; // If this was triggered from user interaction, save the setting if (settingName) { // var settingName = e.target.dataset.setting; var obj = {}; log(settingName, value); // const prefs = { ...this.state.prefs }; prefs[settingName] = value; obj[settingName] = prefs[settingName]; this.setState({ prefs }); // We always save locally so that it gets fetched // faster on future loads. db.sync.set(obj, function () { alertsService.add('Setting saved'); }); if (window.user) { window.db.getDb().then(remoteDb => { remoteDb .collection('users') .doc(window.user.uid) .update({ [`settings.${settingName}`]: this.state.prefs[settingName] }) .then(arg => { log(`Setting "${settingName}" for user`, arg); }) .catch(error => log(error)); }); } trackEvent('ui', 'updatePref-' + settingName, prefs[settingName]); } this.contentWrap.applyCodemirrorSettings(prefs); if (prefs.autoSave) { if (!this.autoSaveInterval) { this.autoSaveInterval = setInterval(() => { this.autoSaveLoop(); }, this.AUTO_SAVE_INTERVAL); } } else { clearInterval(this.autoSaveInterval); this.autoSaveInterval = null; } document.body.classList[prefs.lightVersion ? 'add' : 'remove']( 'light-version' ); } // Keeps getting called after certain interval to auto-save current creation // if it needs to be. autoSaveLoop() { if (this.isAutoSavingEnabled && this.state.unsavedEditCount) { this.saveItem(); } } loginBtnClickHandler() { this.setState({ isLoginModalOpen: true }); } profileBtnClickHandler() { this.setState({ isProfileModalOpen: true }); } logout() { if (this.state.unsavedEditCount) { var shouldDiscard = confirm( 'You have unsaved changes. Do you still want to logout?' ); if (!shouldDiscard) { return; } } trackEvent('fn', 'loggedOut'); auth.logout(); this.setState({ isProfileModalOpen: false }); alertsService.add('Log out successfull'); } itemClickHandler(item) { setTimeout(() => { this.openItem(item); }, 350); this.toggleSavedItemsPane(); } itemRemoveBtnClickHandler(item) { this.removeItem(item); } itemForkBtnClickHandler(item) { this.toggleSavedItemsPane(); setTimeout(() => { this.forkItem(item); }, 350); } openNewCreationModal() { if (this.state.unsavedEditCount) { var shouldDiscard = confirm( 'You have unsaved changes. Do you still want to create something new?' ); if (shouldDiscard) { this.setState({ isCreateNewModalOpen: true }); } } else { this.setState({ isCreateNewModalOpen: true }); } } newBtnClickHandler() { trackEvent('ui', 'newBtnClick'); this.openNewCreationModal(); } openBtnClickHandler() { trackEvent('ui', 'openBtnClick'); this.openSavedItemsPane(); } detachedPreviewBtnHandler() { trackEvent('ui', 'detachPreviewBtnClick'); this.contentWrap.detachPreview(); } notificationsBtnClickHandler() { this.setState({ isNotificationsModalOpen: true }); if (this.state.isNotificationsModalOpen && !this.hasSeenNotifications) { this.hasSeenNotifications = true; this.setState({ hasUnseenChangelog: false }); window.db.setUserLastSeenVersion(version); } trackEvent('ui', 'notificationButtonClick', version); return false; } codepenBtnClickHandler(e) { if (this.state.currentItem.cssMode === CssModes.ACSS) { alert( "Oops! CodePen doesn't supports Atomic CSS currently. \nHere is something you can still do -> https://medium.com/web-maker/sharing-your-atomic-css-work-on-codepen-a402001b26ab" ); e.preventDefault(); return; } var json = { title: 'A Web Maker experiment', html: this.state.currentItem.html, css: this.state.currentItem.css, js: this.state.currentItem.js, /* eslint-disable camelcase */ html_pre_processor: modes[this.state.currentItem.htmlMode].codepenVal, css_pre_processor: modes[this.state.currentItem.cssMode].codepenVal, js_pre_processor: modes[this.state.currentItem.jsMode].codepenVal, css_external: this.state.currentItem.externalLibs.css .split('\n') .join(';'), js_external: this.state.currentItem.externalLibs.js.split('\n').join(';') /* eslint-enable camelcase */ }; if (!this.state.currentItem.title.match(/Untitled\s\d\d*-\d/)) { json.title = this.state.currentItem.title; } json = JSON.stringify(json); window.codepenForm.querySelector('input').value = json; window.codepenForm.submit(); trackEvent('ui', 'openInCodepen'); e.preventDefault(); } saveHtmlBtnClickHandler(e) { saveAsHtml(this.state.currentItem); trackEvent('ui', 'saveHtmlClick'); e.preventDefault(); } runBtnClickHandler() { this.contentWrap.setPreviewContent(true, true); trackEvent('ui', 'runBtnClick'); } exportItems() { handleDownloadsPermission().then(() => { this.fetchItems().then(items => { var d = new Date(); var fileName = [ 'web-maker-export', d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds() ].join('-'); fileName += '.json'; var blob = new Blob([JSON.stringify(items, false, 2)], { type: 'application/json;charset=UTF-8' }); downloadFile(fileName, blob); trackEvent('fn', 'exportItems'); }); }); } exportBtnClickHandler(e) { this.exportItems(); e.preventDefault(); trackEvent('ui', 'exportBtnClicked'); } screenshotBtnClickHandler(e) { this.contentWrap.getDemoFrame(frame => { takeScreenshot(frame.getBoundingClientRect()); }); e.preventDefault(); } openSupportDeveloperModal() { this.closeAllOverlays(); this.setState({ isSupportDeveloperModalOpen: true }); } supportDeveloperBtnClickHandler(e) { this.openSupportDeveloperModal(e); } /** * Called from inside ask-to-import-modal */ dontAskToImportAnymore(e) { this.setState({ isAskToImportModalOpen: false }); window.localStorage[LocalStorageKeys.ASKED_TO_IMPORT_CREATIONS] = true; if (e) { trackEvent('ui', 'dontAskToImportBtnClick'); } } mergeImportedItems(items, isMergingToCloud = false) { var existingItemIds = []; var toMergeItems = {}; const d = deferred(); const { savedItems } = this.state; items.forEach(item => { // We can access `savedItems` here because this gets set when user // opens the saved creations panel. And import option is available // inside the saved items panel. // When we are merging to cloud, savedItems contains the local items. And if we start matching, // all items will match with themselves and nothing would import :P if (!isMergingToCloud && savedItems[item.id]) { // Item already exists existingItemIds.push(item.id); } else { // log('merging', item.id); toMergeItems[item.id] = item; } }); var mergedItemCount = items.length - existingItemIds.length; if (existingItemIds.length) { var shouldReplace = confirm( existingItemIds.length + ' creations already exist. Do you want to replace them?' ); if (shouldReplace) { log('shouldreplace', shouldReplace); items.forEach(item => { toMergeItems[item.id] = item; }); mergedItemCount = items.length; } } if (mergedItemCount) { itemService.saveItems(toMergeItems).then(() => { d.resolve(); alertsService.add( mergedItemCount + ' creations imported successfully.' ); trackEvent('fn', 'itemsImported', mergedItemCount); }); } else { d.resolve(); } this.closeSavedItemsPane(); return d.promise; } /** * Called from inside ask-to-import-modal */ importCreationsAndSettingsIntoApp() { this.mergeImportedItems(this.oldSavedItems, true).then(() => { trackEvent('fn', 'oldItemsImported'); this.dontAskToImportAnymore(); }); } editorFocusHandler(editor) { this.editorWithFocus = editor; } modalOverlayClickHandler() { this.closeAllOverlays(); } splitUpdateHandler(sizes) { const { currentItem } = this.state; if (!sizes) { currentItem.mainSizes = this.getMainPaneSizes(); currentItem.sizes = this.getCodePaneSizes(); return; } // Not using setState to avoid re-render if (sizes.length === 3) { if (currentItem.files) { currentItem.mainSizes = sizes; } else { currentItem.sizes = sizes; } } else { currentItem.mainSizes = sizes; } } /** * Calculate byte size of a text snippet * @author Lea Verou * MIT License */ calculateTextSize(text) { if (!text) { return 0; } var crlf = /(\r?\n|\r)/g, whitespace = /(\r?\n|\r|\s+)/g; const ByteSize = { count: function (text, options) { // Set option defaults options = options || {}; options.lineBreaks = options.lineBreaks || 1; options.ignoreWhitespace = options.ignoreWhitespace || false; var length = text.length, nonAscii = length - text.replace(/[\u0100-\uFFFF]/g, '').length, lineBreaks = length - text.replace(crlf, '').length; if (options.ignoreWhitespace) { // Strip whitespace text = text.replace(whitespace, ''); return text.length + nonAscii; } else { return ( length + nonAscii + Math.max(0, options.lineBreaks * (lineBreaks - 1)) ); } }, format: function (count, plainText) { var level = 0; while (count > 1024) { count /= 1024; level++; } // Round to 2 decimals count = Math.round(count * 100) / 100; level = ['', 'K', 'M', 'G', 'T'][level]; return ( (plainText ? count : '' + count + '') + ' ' + level + 'B' ); } }; return ByteSize.count(text); } getExternalLibCode() { const item = this.state.currentItem; var libs = (item.externalLibs && item.externalLibs.js) || ''; libs += ('\n' + item.externalLibs && item.externalLibs.css) || ''; libs = libs.split('\n').filter(lib => lib); return libs.map(lib => fetch(lib) .then(res => res.text()) .then(data => { return { code: data, fileName: getFilenameFromUrl(lib) }; }) ); } calculateCodeSize() { const item = this.state.currentItem; var htmlPromise = computeHtml(item.html, item.htmlMode); var cssPromise = computeCss(item.css, item.cssMode); var jsPromise = computeJs(item.js, item.jsMode, false); Promise.all([ htmlPromise, cssPromise, jsPromise, ...this.getExternalLibCode() ]).then(result => { var html = result[0].code || '', css = result[1].code || '', js = result[2].code || ''; var fileContent = getCompleteHtml(html, css, js, item, true); // Replace external lib urls with local relative urls (picked from zip) fileContent = fileContent.replace( /