1
0
mirror of https://github.com/chinchang/web-maker.git synced 2025-07-11 09:06:23 +02:00

Merge pull request #329 from chinchang/files

Files support 🔥
This commit is contained in:
Kushagra Gour
2018-10-15 15:08:34 +05:30
committed by GitHub
17 changed files with 1689 additions and 144 deletions

View File

@ -34,7 +34,6 @@ export default class ContentWrap extends Component {
this.logCount = 0;
window.onMessageFromConsole = this.onMessageFromConsole.bind(this);
window.previewException = this.previewException.bind(this);
// `clearConsole` is on window because it gets called from inside iframe also.
window.clearConsole = this.clearConsole.bind(this);
@ -289,50 +288,25 @@ export default class ContentWrap extends Component {
]).then(() => this.setPreviewContent(true));
}
applyCodemirrorSettings(prefs) {
if (!this.cm) {
return;
if (window.consoleEl) {
window.consoleEl.querySelector(
'.CodeMirror'
).style.fontSize = `${parseInt(prefs.fontSize, 10)}px`;
}
htmlCodeEl.querySelector(
'.CodeMirror'
).style.fontSize = cssCodeEl.querySelector(
'.CodeMirror'
).style.fontSize = jsCodeEl.querySelector(
'.CodeMirror'
).style.fontSize = `${parseInt(prefs.fontSize, 10)}px`;
window.consoleEl.querySelector('.CodeMirror').style.fontSize = `${parseInt(
prefs.fontSize,
10
)}px`;
// Replace correct css file in LINK tags's href
window.editorThemeLinkTag.href = `lib/codemirror/theme/${
prefs.editorTheme
}.css`;
if (prefs.editorTheme) {
window.editorThemeLinkTag.href = `lib/codemirror/theme/${
prefs.editorTheme
}.css`;
}
window.fontStyleTag.textContent = window.fontStyleTemplate.textContent.replace(
/fontname/g,
(prefs.editorFont === 'other'
? prefs.editorCustomFont
: prefs.editorFont) || 'FiraCode'
);
// window.customEditorFontInput.classList[
// prefs.editorFont === 'other' ? 'remove' : 'add'
// ]('hide');
this.consoleCm.setOption('theme', prefs.editorTheme);
['html', 'js', 'css'].forEach(type => {
this.cm[type].setOption('indentWithTabs', prefs.indentWith !== 'spaces');
this.cm[type].setOption(
'blastCode',
prefs.isCodeBlastOn ? { effect: 2, shake: false } : false
);
this.cm[type].setOption('indentUnit', +prefs.indentSize);
this.cm[type].setOption('tabSize', +prefs.indentSize);
this.cm[type].setOption('theme', prefs.editorTheme);
this.cm[type].setOption('keyMap', prefs.keymap);
this.cm[type].setOption('lineWrapping', prefs.lineWrap);
this.cm[type].refresh();
});
}
// Check all the code wrap if they are minimized or maximized

View File

@ -0,0 +1,640 @@
import { h, Component } from 'preact';
import UserCodeMirror from './UserCodeMirror';
import { modes, HtmlModes, CssModes, JsModes } from '../codeModes';
import { log, loadJS } from '../utils';
import { linearizeFiles, assignFilePaths } from '../fileUtils';
import { SplitPane } from './SplitPane';
import { trackEvent } from '../analytics';
import CodeMirror from '../CodeMirror';
import { deferred } from '../deferred';
import { SidePane } from './SidePane';
import { Console } from './Console';
const minCodeWrapSize = 33;
/* global htmlCodeEl, jsCodeEl, cssCodeEl, logCountEl
*/
export default class ContentWrapFiles extends Component {
constructor(props) {
super(props);
this.state = {
isConsoleOpen: false,
isCssSettingsModalOpen: false,
editorOptions: this.getEditorOptions()
};
this.fileBuffers = {};
this.updateTimer = null;
this.updateDelay = 500;
this.htmlMode = HtmlModes.HTML;
this.prefs = {};
this.codeInPreview = { html: null, css: null, js: null };
this.cmCodes = { html: props.currentItem.html, css: '', js: '' };
this.logCount = 0;
window.onMessageFromConsole = this.onMessageFromConsole.bind(this);
window.previewException = this.previewException.bind(this);
// `clearConsole` is on window because it gets called from inside iframe also.
window.clearConsole = this.clearConsole.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.state.isConsoleOpen !== nextState.isConsoleOpen ||
this.state.isCssSettingsModalOpen !== nextState.isCssSettingsModalOpen ||
this.state.mainSplitSizes !== nextState.mainSplitSizes ||
this.state.selectedFile !== nextState.selectedFile ||
this.props.currentLayoutMode !== nextProps.currentLayoutMode ||
this.props.currentItem !== nextProps.currentItem ||
this.props.prefs !== nextProps.prefs
);
}
componentWillUpdate(nextProps) {
if (
this.props.currentItem.createdOn !== nextProps.currentItem.createdOn ||
this.props.currentItem.id !== nextProps.currentItem.id
) {
this.fileBuffers = {};
this.state.selectedFile = null;
}
}
componentDidUpdate() {
const { currentItem } = this.props;
const linearFiles = linearizeFiles(currentItem.files);
// Select a new file if nothing is selected already or the selected file exists no more.
if (
currentItem &&
currentItem.files &&
(!this.state.selectedFile ||
!linearFiles.includes(this.state.selectedFile))
) {
this.fileSelectHandler(linearFiles[0]);
}
// HACK: becuase its a DOM manipulation
// window.logCountEl.textContent = this.logCount;
// log('🚀', 'didupdate', this.props.currentItem);
// if (this.isValidItem(this.props.currentItem)) {
// this.refreshEditor();
// }
}
componentDidMount() {
this.props.onRef(this);
}
getEditorOptions(fileName = '') {
let options = {
gutters: [
'error-gutter',
'CodeMirror-linenumbers',
'CodeMirror-foldgutter'
],
emmet: true
};
if (fileName.match(/\.css$/)) {
} else if (fileName.match(/\.js$/)) {
delete options.emmet;
} else if (fileName.match(/\.html$/)) {
// HTML
options = {
...options,
noAutocomplete: true,
matchTags: { bothTags: true }
};
}
return options;
}
createEditorDoc(file) {
let mode;
if (file.name.match(/\.css$/)) {
mode = modes[CssModes.CSS];
} else if (file.name.match(/\.js$/)) {
mode = modes[JsModes.JS];
} else if (file.name.match(/\.html$/)) {
mode = modes[HtmlModes.HTML];
} else if (file.name.match(/\.md$/) || file.name.match(/\.markdown$/)) {
mode = modes[HtmlModes.MARKDOWN];
} else if (file.name.match(/\.sass$/)) {
mode = modes[CssModes.SASS];
} else if (file.name.match(/\.scss$/)) {
mode = modes[CssModes.SCSS];
}
CodeMirror.autoLoadMode(this.cm, mode.cmPath || mode.cmMode);
this.fileBuffers[file.name] = CodeMirror.Doc(
file.content || '',
mode.cmMode
);
}
onHtmlCodeChange(editor, change) {
this.cmCodes.html = editor.getValue();
this.props.onCodeChange(
this.state.selectedFile,
this.cmCodes.html,
change.origin !== 'setValue'
);
this.onCodeChange(editor, change);
}
onCodeChange(editor, change) {
clearTimeout(this.updateTimer);
this.updateTimer = setTimeout(() => {
// This is done so that multiple simultaneous setValue don't trigger too many preview refreshes
// and in turn too many file writes on a single file (eg. preview.html).
if (change.origin !== 'setValue') {
// Specifically checking for false so that the condition doesn't get true even
// on absent key - possible when the setting key hasn't been fetched yet.
if (this.prefs.autoPreview !== false) {
this.setPreviewContent();
}
// Track when people actually are working.
trackEvent.previewCount = (trackEvent.previewCount || 0) + 1;
if (trackEvent.previewCount === 4) {
trackEvent('fn', 'usingPreview');
}
}
}, this.updateDelay);
}
createPreviewFile(html, css, js) {
// Track if people have written code.
if (!trackEvent.hasTrackedCode && (html || css || js)) {
trackEvent('fn', 'hasCode');
trackEvent.hasTrackedCode = true;
}
var obj = {};
const duplicateFiles = JSON.parse(
JSON.stringify(this.props.currentItem.files)
);
const files = linearizeFiles(assignFilePaths(duplicateFiles, '/user'));
files.forEach(file => {
obj[file.path] = file.content || '';
// Add screenlog to index.html
if (file.name === 'index.html') {
obj[file.path] =
'<script src="' +
(chrome.extension
? chrome.extension.getURL('lib/screenlog.js')
: `${location.origin}${
window.DEBUG ? '' : BASE_PATH
}/lib/screenlog.js`) +
'"></script>' +
obj[file.path];
}
});
navigator.serviceWorker.controller.postMessage(obj);
if (this.detachedWindow) {
log('✉️ Sending message to detached window');
this.detachedWindow.postMessage({ contents: '/user/index.html' }, '*');
} else {
this.frame.src = '/user/index.html';
}
}
cleanupErrors() {
this.cm.clearGutter('error-gutter');
}
showErrors(lang, errors) {
var editor = this.cm;
errors.forEach(function(e) {
editor.operation(function() {
var n = document.createElement('div');
n.setAttribute('data-title', e.message);
n.classList.add('gutter-error-marker');
editor.setGutterMarker(e.lineNumber, 'error-gutter', n);
});
});
}
/**
* Generates the preview from the current code.
* @param {boolean} isForced Should refresh everything without any check or not
* @param {boolean} isManual Is this a manual preview request from user?
*/
setPreviewContent(isForced, isManual) {
if (!this.props.prefs.autoPreview && !isManual) {
return;
}
if (!this.props.prefs.preserveConsoleLogs) {
this.clearConsole();
}
this.cleanupErrors();
var currentCode = {
html: this.cmCodes.html,
css: this.cmCodes.css,
js: this.cmCodes.js
};
log('🔎 setPreviewContent', isForced);
const targetFrame = this.detachedWindow
? this.detachedWindow.document.querySelector('iframe')
: this.frame;
this.createPreviewFile();
// result.forEach(resultItem => {
// if (resultItem.errors) {
// this.showErrors(resultItem.errors.lang, resultItem.errors.data);
// }
// });
this.codeInPreview.html = currentCode.html;
this.codeInPreview.css = currentCode.css;
this.codeInPreview.js = currentCode.js;
}
isValidItem(item) {
return !!item.title;
}
refreshEditor() {
this.cmCodes.html = this.props.currentItem.html;
if (this.state.selectedFile) {
this.cm.setValue(this.state.selectedFile.content);
}
this.cm.refresh();
window.cm = this.cm;
this.clearConsole();
// Set preview only when all modes are updated so that preview doesn't generate on partially
// correct modes and also doesn't happen 3 times.
Promise.all([this.updateHtmlMode(this.props.currentItem.htmlMode)]).then(
() => this.setPreviewContent(true)
);
}
applyCodemirrorSettings(prefs) {
if (window.consoleEl) {
window.consoleEl.querySelector(
'.CodeMirror'
).style.fontSize = `${parseInt(prefs.fontSize, 10)}px`;
}
// Replace correct css file in LINK tags's href
if (prefs.editorTheme) {
window.editorThemeLinkTag.href = `lib/codemirror/theme/${
prefs.editorTheme
}.css`;
}
window.fontStyleTag.textContent = window.fontStyleTemplate.textContent.replace(
/fontname/g,
(prefs.editorFont === 'other'
? prefs.editorCustomFont
: prefs.editorFont) || 'FiraCode'
);
}
// Check all the code wrap if they are minimized or maximized
updateCodeWrapCollapseStates() {
// This is debounced!
clearTimeout(this.updateCodeWrapCollapseStates.timeout);
this.updateCodeWrapCollapseStates.timeout = setTimeout(() => {
const { currentLayoutMode } = this.props;
const prop =
currentLayoutMode === 2 || currentLayoutMode === 5 ? 'width' : 'height';
[htmlCodeEl].forEach(function(el) {
const bounds = el.getBoundingClientRect();
const size = bounds[prop];
if (size < 100) {
el.classList.add('is-minimized');
} else {
el.classList.remove('is-minimized');
}
if (el.style[prop].indexOf(`100% - ${minCodeWrapSize * 2}px`) !== -1) {
el.classList.add('is-maximized');
} else {
el.classList.remove('is-maximized');
}
});
}, 50);
}
resetSplitting() {
this.setState({
mainSplitSizes: this.getMainSplitSizesToApply()
});
}
updateSplits() {
this.props.onSplitUpdate();
// Not using setState to avoid re-render
this.state.mainSplitSizes = this.props.currentItem.mainSizes;
}
// Returns the sizes of main code & preview panes.
getMainSplitSizesToApply() {
var mainSplitSizes;
const sidebarWidth = 200;
const { currentItem, currentLayoutMode } = this.props;
if (false && currentItem && currentItem.mainSizes) {
// For layout mode 3, main panes are reversed using flex-direction.
// So we need to apply the saved sizes in reverse order.
mainSplitSizes =
currentLayoutMode === 3
? [currentItem.mainSizes[1], currentItem.mainSizes[0]]
: currentItem.mainSizes;
} else {
mainSplitSizes = [
`${sidebarWidth}px`,
`calc(50% - ${sidebarWidth / 2}px)`,
`calc(50% - ${sidebarWidth / 2}px)`
];
}
return mainSplitSizes;
}
mainSplitDragEndHandler() {
if (this.props.prefs.refreshOnResize) {
// Running preview updation in next call stack, so that error there
// doesn't affect this dragend listener.
setTimeout(() => {
this.setPreviewContent(true);
}, 1);
}
this.updateSplits();
}
/**
* Loaded the code comiler based on the mode selected
*/
handleModeRequirements(mode) {
const baseTranspilerPath = 'lib/transpilers';
// Exit if already loaded
var d = deferred();
if (modes[mode].hasLoaded) {
d.resolve();
return d.promise;
}
function setLoadedFlag() {
modes[mode].hasLoaded = true;
d.resolve();
}
if (mode === HtmlModes.JADE) {
loadJS(`${baseTranspilerPath}/jade.js`).then(setLoadedFlag);
} else if (mode === HtmlModes.MARKDOWN) {
loadJS(`${baseTranspilerPath}/marked.js`).then(setLoadedFlag);
} else if (mode === CssModes.LESS) {
loadJS(`${baseTranspilerPath}/less.min.js`).then(setLoadedFlag);
} else if (mode === CssModes.SCSS || mode === CssModes.SASS) {
loadJS(`${baseTranspilerPath}/sass.js`).then(function() {
window.sass = new Sass(`${baseTranspilerPath}/sass.worker.js`);
setLoadedFlag();
});
} else if (mode === CssModes.STYLUS) {
loadJS(`${baseTranspilerPath}/stylus.min.js`).then(setLoadedFlag);
} else if (mode === CssModes.ACSS) {
loadJS(`${baseTranspilerPath}/atomizer.browser.js`).then(setLoadedFlag);
} else if (mode === JsModes.COFFEESCRIPT) {
loadJS(`${baseTranspilerPath}/coffee-script.js`).then(setLoadedFlag);
} else if (mode === JsModes.ES6) {
loadJS(`${baseTranspilerPath}/babel.min.js`).then(setLoadedFlag);
} else if (mode === JsModes.TS) {
loadJS(`${baseTranspilerPath}/typescript.js`).then(setLoadedFlag);
} else {
d.resolve();
}
return d.promise;
}
updateHtmlMode(value) {
// this.props.onCodeModeChange('html', value);
// this.props.currentItem.htmlMode = value;
// this.cm.setOption('mode', modes[value].cmMode);
// CodeMirror.autoLoadMode(
// this.cm,
// modes[value].cmPath || modes[value].cmMode
// );
// return this.handleModeRequirements(value);
}
updateCssMode(value) {
// this.props.onCodeModeChange('css', value);
// this.props.currentItem.cssMode = value;
this.cm.setOption('mode', modes[value].cmMode);
this.cm.setOption('readOnly', modes[value].cmDisable);
/* window.cssSettingsBtn.classList[
modes[value].hasSettings ? 'remove' : 'add'
]('hide'); */
CodeMirror.autoLoadMode(
this.cm,
modes[value].cmPath || modes[value].cmMode
);
return this.handleModeRequirements(value);
}
updateJsMode(value) {
this.cm.setOption('mode', modes[value].cmMode);
CodeMirror.autoLoadMode(
this.cm,
modes[value].cmPath || modes[value].cmMode
);
return this.handleModeRequirements(value);
}
getDemoFrame(callback) {
callback(this.frame);
}
editorFocusHandler(editor) {
this.props.onEditorFocus(editor);
}
fileSelectHandler(file) {
if (file.isFolder) {
this.props.onFolderSelect(file);
return;
}
this.setState({
editorOptions: this.getEditorOptions(file.name),
selectedFile: file
});
if (!this.fileBuffers[file.name]) {
this.createEditorDoc(file);
}
this.cm.swapDoc(this.fileBuffers[file.name]);
// var cmMode = 'html';
// if (file.name.match(/\.css$/)) {
// this.updateCssMode('css');
// } else if (file.name.match(/\.js$/)) {
// this.updateCssMode('js');
// } else {
// this.updateCssMode('html');
// }
// this.cm.setValue(file.content || '');
this.cm.focus();
}
updateLogCount() {
if (window.logCountEl) {
logCountEl.textContent = this.logCount;
}
}
onMessageFromConsole() {
/* eslint-disable no-param-reassign */
[...arguments].forEach(arg => {
if (
arg &&
arg.indexOf &&
arg.indexOf('filesystem:chrome-extension') !== -1
) {
arg = arg.replace(
/filesystem:chrome-extension.*\.js:(\d+):*(\d*)/g,
'script $1:$2'
);
}
try {
this.consoleCm.replaceRange(
arg +
' ' +
((arg + '').match(/\[object \w+]/) ? JSON.stringify(arg) : '') +
'\n',
{
line: Infinity
}
);
} catch (e) {
this.consoleCm.replaceRange('🌀\n', {
line: Infinity
});
}
this.consoleCm.scrollTo(0, Infinity);
this.logCount++;
});
this.updateLogCount();
/* eslint-enable no-param-reassign */
}
previewException(error) {
console.error('Possible infinite loop detected.', error.stack);
this.onMessageFromConsole('Possible infinite loop detected.', error.stack);
}
toggleConsole() {
this.setState({ isConsoleOpen: !this.state.isConsoleOpen });
trackEvent('ui', 'consoleToggle');
}
consoleHeaderDblClickHandler(e) {
if (!e.target.classList.contains('js-console__header')) {
return;
}
trackEvent('ui', 'consoleToggleDblClick');
this.toggleConsole();
}
clearConsole() {
this.consoleCm.setValue('');
this.logCount = 0;
this.updateLogCount();
}
clearConsoleBtnClickHandler() {
this.clearConsole();
trackEvent('ui', 'consoleClearBtnClick');
}
evalConsoleExpr(e) {
// Clear console on CTRL + L
if ((e.which === 76 || e.which === 12) && e.ctrlKey) {
this.clearConsole();
trackEvent('ui', 'consoleClearKeyboardShortcut');
} else if (e.which === 13) {
this.onMessageFromConsole('> ' + e.target.value);
/* eslint-disable no-underscore-dangle */
this.frame.contentWindow._wmEvaluate(e.target.value);
/* eslint-enable no-underscore-dangle */
e.target.value = '';
trackEvent('fn', 'evalConsoleExpr');
}
}
render() {
return (
<SplitPane
class="content-wrap flex flex-grow"
sizes={this.state.mainSplitSizes}
minSize={150}
style=""
direction={
this.props.currentLayoutMode === 2 ? 'vertical' : 'horizontal'
}
onDragEnd={this.mainSplitDragEndHandler.bind(this)}
>
<div id="js-sidebar">
<SidePane
files={this.props.currentItem.files || []}
selectedFile={this.state.selectedFile}
onFileSelect={this.fileSelectHandler.bind(this)}
onAddFile={this.props.onAddFile}
onRemoveFile={this.props.onRemoveFile}
onRenameFile={this.props.onRenameFile}
onFileDrop={this.props.onFileDrop}
/>
</div>
<div class="code-side" id="js-code-side">
<div
data-code-wrap-id="0"
id="htmlCodeEl"
data-type="html"
class="code-wrap"
onTransitionEnd={this.updateCodeWrapCollapseStates.bind(this)}
>
<div
class="js-code-wrap__header code-wrap__header"
title="Double click to toggle code pane"
>
<label class="btn-group" dropdow title="Click to change">
{this.state.selectedFile ? this.state.selectedFile.name : ''}
</label>
<div class="code-wrap__header-right-options">
<a
class="js-code-collapse-btn code-wrap__header-btn code-wrap__collapse-btn"
title="Toggle code pane"
/>
</div>
</div>
<UserCodeMirror
options={this.state.editorOptions}
prefs={this.props.prefs}
onChange={this.onHtmlCodeChange.bind(this)}
onCreation={editor => (this.cm = editor)}
onFocus={this.editorFocusHandler.bind(this)}
/>
</div>
</div>
<div class="demo-side" id="js-demo-side" style="">
<iframe
ref={el => (this.frame = el)}
src="/user/index.html"
frameborder="0"
id="demo-frame"
allowfullscreen
/>
<Console
isConsoleOpen={this.state.isConsoleOpen}
onConsoleHeaderDblClick={this.consoleHeaderDblClickHandler.bind(
this
)}
onClearConsoleBtnClick={this.clearConsoleBtnClickHandler.bind(this)}
toggleConsole={this.toggleConsole.bind(this)}
onEvalInputKeyup={this.evalConsoleExpr.bind(this)}
onReady={el => (this.consoleCm = el)}
/>
</div>
</SplitPane>
);
}
}

View File

@ -7,6 +7,7 @@ export function CreateNewModal({
show,
closeHandler,
onBlankTemplateSelect,
onBlankFileTemplateSelect,
onTemplateSelect
}) {
return (
@ -15,6 +16,9 @@ export function CreateNewModal({
<button className="btn" onClick={onBlankTemplateSelect}>
Start a blank creation
</button>
<button className="btn" onClick={onBlankFileTemplateSelect}>
Start a blank files creation
</button>
</div>
<hr />
Or choose from a template:

View File

@ -0,0 +1,99 @@
import { h } from 'preact';
export function FileIcon({ file }) {
let path;
if (file.isFolder) {
if (!file.children.length) {
path = (
<path
fill="currentColor"
d="M20,18H4V8H20M20,6H12L10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6Z"
/>
);
} else {
path = file.isCollapsed ? (
<path
fill="currentColor"
d="M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z"
/>
) : (
<path
fill="currentColor"
d="M19,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10L12,6H19A2,2 0 0,1 21,8H21L4,8V18L6.14,10H23.21L20.93,18.5C20.7,19.37 19.92,20 19,20Z"
/>
);
}
} else {
const type = file.name.match(/.(\w+)$/)[1];
switch (type) {
case 'html':
path = (
<path
fill="rgb(225, 187, 21)"
d="M14.6,16.6L19.2,12L14.6,7.4L16,6L22,12L16,18L14.6,16.6M9.4,16.6L4.8,12L9.4,7.4L8,6L2,12L8,18L9.4,16.6Z"
/>
);
break;
case 'js':
path = (
<path
fill="rgb(255, 165, 0)"
d="M3,3H21V21H3V3M7.73,18.04C8.13,18.89 8.92,19.59 10.27,19.59C11.77,19.59 12.8,18.79 12.8,17.04V11.26H11.1V17C11.1,17.86 10.75,18.08 10.2,18.08C9.62,18.08 9.38,17.68 9.11,17.21L7.73,18.04M13.71,17.86C14.21,18.84 15.22,19.59 16.8,19.59C18.4,19.59 19.6,18.76 19.6,17.23C19.6,15.82 18.79,15.19 17.35,14.57L16.93,14.39C16.2,14.08 15.89,13.87 15.89,13.37C15.89,12.96 16.2,12.64 16.7,12.64C17.18,12.64 17.5,12.85 17.79,13.37L19.1,12.5C18.55,11.54 17.77,11.17 16.7,11.17C15.19,11.17 14.22,12.13 14.22,13.4C14.22,14.78 15.03,15.43 16.25,15.95L16.67,16.13C17.45,16.47 17.91,16.68 17.91,17.26C17.91,17.74 17.46,18.09 16.76,18.09C15.93,18.09 15.45,17.66 15.09,17.06L13.71,17.86Z"
/>
);
break;
case 'css':
path = (
<path
fill="rgb(95, 158, 160)"
d="M5,3L4.35,6.34H17.94L17.5,8.5H3.92L3.26,11.83H16.85L16.09,15.64L10.61,17.45L5.86,15.64L6.19,14H2.85L2.06,18L9.91,21L18.96,18L20.16,11.97L20.4,10.76L21.94,3H5Z"
/>
);
break;
case 'md':
case 'markdown':
path = (
<path
fill="skyblue"
d="M2,16V8H4L7,11L10,8H12V16H10V10.83L7,13.83L4,10.83V16H2M16,8H19V12H21.5L17.5,16.5L13.5,12H16V8Z"
/>
);
break;
case 'jpg':
case 'jpeg':
case 'svg':
case 'png':
path = (
<path
fill="crimson"
d="M19,19H5V5H19M19,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M13.96,12.29L11.21,15.83L9.25,13.47L6.5,17H17.5L13.96,12.29Z"
/>
);
break;
case 'json':
path = (
<path
fill="orange"
d="M5,3H7V5H5V10A2,2 0 0,1 3,12A2,2 0 0,1 5,14V19H7V21H5C3.93,20.73 3,20.1 3,19V15A2,2 0 0,0 1,13H0V11H1A2,2 0 0,0 3,9V5A2,2 0 0,1 5,3M19,3A2,2 0 0,1 21,5V9A2,2 0 0,0 23,11H24V13H23A2,2 0 0,0 21,15V19A2,2 0 0,1 19,21H17V19H19V14A2,2 0 0,1 21,12A2,2 0 0,1 19,10V5H17V3H19M12,15A1,1 0 0,1 13,16A1,1 0 0,1 12,17A1,1 0 0,1 11,16A1,1 0 0,1 12,15M8,15A1,1 0 0,1 9,16A1,1 0 0,1 8,17A1,1 0 0,1 7,16A1,1 0 0,1 8,15M16,15A1,1 0 0,1 17,16A1,1 0 0,1 16,17A1,1 0 0,1 15,16A1,1 0 0,1 16,15Z"
/>
);
break;
default:
path = (
<path
fill="currentColor"
d="M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z"
/>
);
}
}
return (
<svg class="sidebar__file-icon" viewBox="0 0 24 24">
{path}
</svg>
);
}

View File

@ -196,7 +196,7 @@ export default class Footer extends Component {
<button
onClick={this.props.saveHtmlBtnClickHandler}
id="saveHtmlBtn"
class="mode-btn hint--rounded hint--top-left hide-on-mobile"
class="mode-btn hint--rounded hint--top-left hide-on-mobile hide-in-file-mode"
aria-label="Save as HTML file"
>
<svg viewBox="0 0 24 24">
@ -219,7 +219,7 @@ export default class Footer extends Component {
<button
onClick={this.props.codepenBtnClickHandler}
id="codepenBtn"
class="mode-btn hint--rounded hint--top-left hide-on-mobile"
class="mode-btn hint--rounded hint--top-left hide-on-mobile hide-in-file-mode"
aria-label="Edit on CodePen"
>
<svg>
@ -243,7 +243,7 @@ export default class Footer extends Component {
<button
onClick={this.layoutBtnClickhandler.bind(this, 1)}
id="layoutBtn1"
class="mode-btn hide-on-mobile"
class="mode-btn hide-on-mobile hide-in-file-mode"
aria-label="Switch to layout with preview on right"
>
<svg viewBox="0 0 100 100" style="transform:rotate(-90deg)">
@ -253,7 +253,7 @@ export default class Footer extends Component {
<button
onClick={this.layoutBtnClickhandler.bind(this, 2)}
id="layoutBtn2"
class="mode-btn hide-on-mobile"
class="mode-btn hide-on-mobile hide-in-file-mode"
aria-label="Switch to layout with preview on bottom"
>
<svg viewBox="0 0 100 100">
@ -263,7 +263,7 @@ export default class Footer extends Component {
<button
onClick={this.layoutBtnClickhandler.bind(this, 3)}
id="layoutBtn3"
class="mode-btn hide-on-mobile"
class="mode-btn hide-on-mobile hide-in-file-mode"
aria-label="Switch to layout with preview on left"
>
<svg viewBox="0 0 100 100" style="transform:rotate(90deg)">
@ -273,7 +273,7 @@ export default class Footer extends Component {
<button
onClick={this.layoutBtnClickhandler.bind(this, 5)}
id="layoutBtn5"
class="mode-btn hide-on-mobile"
class="mode-btn hide-on-mobile hide-in-file-mode"
aria-label="Switch to layout with all vertical panes"
>
<svg viewBox="0 0 100 100">

View File

@ -49,6 +49,14 @@ export function ItemTile({
</div>
) : null}
<h3 class="saved-item-tile__title">{item.title}</h3>
{item.files ? (
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M15,7H20.5L15,1.5V7M8,0H16L22,6V18A2,2 0 0,1 20,20H8C6.89,20 6,19.1 6,18V2A2,2 0 0,1 8,0M4,4V22H20V24H4A2,2 0 0,1 2,22V4H4Z"
/>
</svg>
) : null}
</div>
{item.updatedOn ? (
<div class="saved-item-tile__meta">

View File

@ -27,22 +27,24 @@ export function MainHeader(props) {
</svg>Run
</button>
<Button
onClick={props.addLibraryBtnHandler}
data-event-category="ui"
data-event-action="addLibraryButtonClick"
class="btn--dark flex-v-center hint--rounded hint--bottom-left"
aria-label="Add a JS/CSS library"
>
Add library{' '}
<span
id="js-external-lib-count"
style={`display:${props.externalLibCount ? 'inline' : 'none'}`}
class="count-label"
{!this.props.isFileMode && (
<Button
onClick={props.addLibraryBtnHandler}
data-event-category="ui"
data-event-action="addLibraryButtonClick"
class="btn--dark flex-v-center hint--rounded hint--bottom-left"
aria-label="Add a JS/CSS library"
>
{props.externalLibCount}
</span>
</Button>
Add library{' '}
<span
id="js-external-lib-count"
style={`display:${props.externalLibCount ? 'inline' : 'none'}`}
class="count-label"
>
{props.externalLibCount}
</span>
</Button>
)}
<button
class="btn--dark flex flex-v-center hint--rounded hint--bottom-left"

View File

@ -33,6 +33,7 @@ export default class Settings extends Component {
return true;
}
render() {
const { prefs } = this.props;
return (
<div>
<h1>Settings</h1>
@ -47,7 +48,7 @@ export default class Settings extends Component {
type="radio"
name="indentation"
value="spaces"
checked={this.props.prefs.indentation === 'spaces'}
checked={prefs.indentWith === 'spaces'}
onChange={this.updateSetting.bind(this)}
data-setting="indentWith"
/>{' '}
@ -58,7 +59,7 @@ export default class Settings extends Component {
type="radio"
name="indentation"
value="tabs"
checked={this.props.prefs.indentation === 'tabs'}
checked={prefs.indentWith === 'tabs'}
onChange={this.updateSetting.bind(this)}
data-setting="indentWith"
/>{' '}
@ -70,14 +71,14 @@ export default class Settings extends Component {
<input
type="range"
class="va-m ml-1"
value={this.props.prefs.indentSize}
value={prefs.indentSize}
min="1"
max="7"
list="indentationSizeList"
data-setting="indentSize"
onChange={this.updateSetting.bind(this)}
/>
<span id="indentationSizeValueEl">{this.props.prefs.indentSize}</span>
<span id="indentationSizeValueEl">{prefs.indentSize}</span>
<datalist id="indentationSizeList">
<option>1</option>
<option>2</option>
@ -99,7 +100,7 @@ export default class Settings extends Component {
<select
style="flex:1;margin-left:20px"
data-setting="htmlMode"
value={this.props.prefs.htmlMode}
value={prefs.htmlMode}
onChange={this.updateSetting.bind(this)}
>
<option value="html">HTML</option>
@ -109,7 +110,7 @@ export default class Settings extends Component {
<select
style="flex:1;margin-left:20px"
data-setting="cssMode"
value={this.props.prefs.cssMode}
value={prefs.cssMode}
onChange={this.updateSetting.bind(this)}
>
<option value="css">CSS</option>
@ -122,7 +123,7 @@ export default class Settings extends Component {
<select
style="flex:1;margin-left:20px"
data-setting="jsMode"
value={this.props.prefs.jsMode}
value={prefs.jsMode}
onChange={this.updateSetting.bind(this)}
>
<option value="js">JS</option>
@ -136,7 +137,7 @@ export default class Settings extends Component {
<select
style="flex:1;margin:0 20px"
data-setting="editorTheme"
value={this.props.prefs.editorTheme}
value={prefs.editorTheme}
onChange={this.updateSetting.bind(this)}
>
{editorThemes.map(theme => (
@ -149,7 +150,7 @@ export default class Settings extends Component {
<select
style="flex:1;margin:0 20px"
data-setting="editorFont"
value={this.props.prefs.editorFont}
value={prefs.editorFont}
onChange={this.updateSetting.bind(this)}
>
<option value="FiraCode">Fira Code</option>
@ -159,11 +160,11 @@ export default class Settings extends Component {
<option disabled="disabled">----</option>
<option value="other">Other font from system</option>
</select>
{this.props.prefs.editorFont === 'other' && (
{prefs.editorFont === 'other' && (
<input
id="customEditorFontInput"
type="text"
value={this.props.prefs.editorCustomFont}
value={prefs.editorCustomFont}
placeholder="Custom font name here"
data-setting="editorCustomFont"
onChange={this.updateSetting.bind(this)}
@ -175,7 +176,7 @@ export default class Settings extends Component {
<input
style="width:70px"
type="number"
value={this.props.prefs.fontSize}
value={prefs.fontSize}
data-setting="fontSize"
onChange={this.updateSetting.bind(this)}
/>{' '}
@ -188,7 +189,7 @@ export default class Settings extends Component {
type="radio"
name="keymap"
value="sublime"
checked={this.props.prefs.keymap === 'sublime'}
checked={prefs.keymap === 'sublime'}
data-setting="keymap"
onChange={this.updateSetting.bind(this)}
/>{' '}
@ -199,7 +200,7 @@ export default class Settings extends Component {
type="radio"
name="keymap"
value="vim"
checked={this.props.prefs.keymap === 'vim'}
checked={prefs.keymap === 'vim'}
data-setting="keymap"
onChange={this.updateSetting.bind(this)}
/>{' '}
@ -212,49 +213,49 @@ export default class Settings extends Component {
name="lineWrap"
title="Toggle wrapping of long sentences onto new line"
label="Line wrap"
pref={this.props.prefs.lineWrap}
pref={prefs.lineWrap}
onChange={this.updateSetting.bind(this)}
/>
<CheckboxSetting
name="refreshOnResize"
title="Your Preview will refresh when you resize the preview split"
label="Refresh preview on resize"
pref={this.props.prefs.refreshOnResize}
pref={prefs.refreshOnResize}
onChange={this.updateSetting.bind(this)}
/>
<CheckboxSetting
name="autoComplete"
title="Turns on the auto-completion suggestions as you type"
label="Auto-complete suggestions"
pref={this.props.prefs.autoComplete}
pref={prefs.autoComplete}
onChange={this.updateSetting.bind(this)}
/>
<CheckboxSetting
name="autoPreview"
title="Refreshes the preview as you code. Otherwise use the Run button"
label="Auto-preview"
pref={this.props.prefs.autoPreview}
pref={prefs.autoPreview}
onChange={this.updateSetting.bind(this)}
/>
<CheckboxSetting
name="autoSave"
title="Auto-save keeps saving your code at regular intervals after you hit the first save manually"
label="Auto-save"
pref={this.props.prefs.autoSave}
pref={prefs.autoSave}
onChange={this.updateSetting.bind(this)}
/>
<CheckboxSetting
name="preserveLastCode"
title="Loads the last open creation when app starts"
label="Preserve last written code"
pref={this.props.prefs.preserveLastCode}
pref={prefs.preserveLastCode}
onChange={this.updateSetting.bind(this)}
/>
<CheckboxSetting
name="replaceNewTab"
title="Turning this on will start showing Web Maker in every new tab you open"
label="Replace new tab page"
pref={this.props.prefs.replaceNewTab}
pref={prefs.replaceNewTab}
onChange={this.updateSetting.bind(this)}
showWhenExtension
/>
@ -262,14 +263,14 @@ export default class Settings extends Component {
name="preserveConsoleLogs"
title="Preserves the console logs across your preview refreshes"
label="Preserve console logs"
pref={this.props.prefs.preserveConsoleLogs}
pref={prefs.preserveConsoleLogs}
onChange={this.updateSetting.bind(this)}
/>
<CheckboxSetting
name="lightVersion"
title="Switch to lighter version for better performance. Removes things like blur etc."
label="Fast/light version"
pref={this.props.prefs.lightVersion}
pref={prefs.lightVersion}
onChange={this.updateSetting.bind(this)}
/>
</div>
@ -283,7 +284,7 @@ export default class Settings extends Component {
title="Enjoy wonderful particle blasts while you type"
label="Code blast!"
name="isCodeBlastOn"
pref={this.props.prefs.isCodeBlastOn}
pref={prefs.isCodeBlastOn}
onChange={this.updateSetting.bind(this)}
/>
@ -291,7 +292,7 @@ export default class Settings extends Component {
title="Get ready to build some games at JS13KGames"
label="Js13kGames Mode"
name="isJs13kModeOn"
pref={this.props.prefs.isJs13kModeOn}
pref={prefs.isJs13kModeOn}
onChange={this.updateSetting.bind(this)}
/>
</p>
@ -307,7 +308,7 @@ export default class Settings extends Component {
Maximum time allowed in a loop iteration{' '}
<input
type="number"
value={this.props.prefs.infiniteLoopTimeout}
value={prefs.infiniteLoopTimeout}
data-setting="infiniteLoopTimeout"
onChange={this.updateSetting.bind(this)}
/>{' '}

295
src/components/SidePane.jsx Normal file
View File

@ -0,0 +1,295 @@
import { h, Component } from 'preact';
import { FileIcon } from './FileIcon';
import { getParentPath, getFileFromPath } from '../fileUtils';
const ENTER_KEY = 13;
const ESCAPE_KEY = 27;
function File({
file,
selectedFile,
fileBeingRenamed,
onFileSelect,
onRenameBtnClick,
onRemoveBtnClick,
onNameInputBlur,
onNameInputKeyUp,
onFileDrop
}) {
function focusInput(el) {
el &&
setTimeout(() => {
el.focus();
}, 1);
}
function dragStartHandler(e) {
e.dataTransfer.setData('text/plain', file.path);
}
function dragOverHandler(e) {
if (file.isFolder) {
e.preventDefault();
// e.stopPropagation();
e.currentTarget.classList.add('is-being-dragged-over');
e.currentTarget.style.outline = '1px dashed';
}
}
function dragLeaveHandler(e) {
if (file.isFolder) {
e.preventDefault();
e.currentTarget.style.outline = null;
}
}
function dropHandler(e) {
e.stopPropagation();
if (file.isFolder) {
e.preventDefault();
onFileDrop(e.dataTransfer.getData('text/plain'), file);
e.currentTarget.style.outline = null;
}
}
return (
<div
onDragOver={dragOverHandler}
onDragLeave={dragLeaveHandler}
onDrop={dropHandler}
>
{file === fileBeingRenamed ? (
<input
type="text"
ref={focusInput}
value={file.name}
onBlur={onNameInputBlur}
onKeyUp={onNameInputKeyUp}
/>
) : (
<button
class={`sidebar__file ${selectedFile === file ? 'selected' : ''}`}
type="button"
draggable={file.name !== 'index.html'}
onDragStart={dragStartHandler}
onClick={onFileSelect.bind(null, file)}
>
<div class="flex flex-v-center">
<FileIcon file={file} />
{file.name}
</div>
<div class="sidebar__file-options">
<button
type="button"
class="btn btn--dark"
onClick={onRenameBtnClick.bind(null, file)}
>
<svg
viewBox="0 0 24 24"
style="vertical-align:middle;width:14px;height:14px"
>
<path d="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z" />
</svg>
</button>
<button
type="button"
class="btn btn--dark"
onClick={onRemoveBtnClick.bind(null, file)}
>
<svg
viewBox="0 0 24 24"
style="vertical-align:middle;width:14px;height:14px"
>
<path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z" />
</svg>
</button>
</div>
</button>
)}
</div>
);
}
function Folder(props) {
return (
<div>
<File {...props} file={props.file} />
<div class="sidebar__folder-wrap" data-collapsed={props.file.isCollapsed}>
{props.file.children.map(
childFile =>
childFile.isFolder ? (
<Folder {...props} file={childFile} />
) : (
<File {...props} file={childFile} />
)
)}
</div>
</div>
);
}
export class SidePane extends Component {
addFileButtonClickHandler() {
this.setState({ isAddingFile: true });
}
addFolderButtonClickHandler() {
this.setState({ isAddingFolder: true });
}
/**
* Checks if the passed filename already exists and if so, warns the user.
* Also it passes false if the validation fails.
* @param {string} newFileName New file name to validate
*/
warnForExistingFileName(newFileName) {
// We also check for fileBeingRenamed !== file because when a file being renamed is
// asked to be set as same name, then that should not match and warn here.
let newPath = this.state.fileBeingRenamed
? `${getParentPath(this.state.fileBeingRenamed.path)}/${newFileName}`
: newFileName;
// remove first slash
newPath = newPath.replace(/^\//, '');
const match = getFileFromPath(this.props.files, newPath);
if (match.file && this.state.fileBeingRenamed !== match.file) {
alert(`A file with name ${newFileName} already exists.`);
return false;
}
return true;
}
addFile(e) {
// This gets called twice when enter is pressed, because blur also fires.
// So check `isAddingFile` before proceeding.
if (!this.state.isAddingFile && !this.state.isAddingFolder) {
return;
}
const newFileName = e.target.value;
if (!this.warnForExistingFileName(newFileName)) {
return;
}
if (newFileName) {
this.props.onAddFile(newFileName, this.state.isAddingFolder);
}
this.setState({ isAddingFile: false, isAddingFolder: false });
}
newFileNameInputKeyDownHandler(e) {
if (e.which === ENTER_KEY) {
this.addFile(e);
} else if (e.which === ESCAPE_KEY) {
this.setState({ isAddingFile: false, isAddingFolder: false });
}
}
removeFileClickHandler(file, e) {
e.stopPropagation();
const answer = confirm(`Are you sure you want to delete "${file.name}"?`);
if (answer) {
this.props.onRemoveFile(file.path);
}
}
renameFile(e) {
// This gets called twice when enter is pressed, because blur also fires.
if (!e.target || !this.state.fileBeingRenamed) {
return;
}
const newFileName = e.target.value;
if (!this.warnForExistingFileName(newFileName)) {
return;
}
if (newFileName) {
this.props.onRenameFile(this.state.fileBeingRenamed.path, newFileName);
}
this.setState({ fileBeingRenamed: null });
}
renameFileNameInputKeyUpHandler(e) {
if (e.which === ENTER_KEY) {
this.renameFile(e);
} else if (e.which === ESCAPE_KEY) {
this.setState({ fileBeingRenamed: null });
}
}
renameFileClickHandler(file, e) {
e.stopPropagation();
this.setState({
fileBeingRenamed: file
});
}
dragOverHandler(e) {
e.preventDefault();
}
dropHandler(e) {
e.preventDefault();
// Object with `children` key is to simulate a folder structure for root folder
this.props.onFileDrop(e.dataTransfer.getData('text/plain'), {
children: this.props.files
});
// e.currentTarget.style.outline = null;
}
render() {
const { files, onFileSelect, selectedFile, onRemoveFile } = this.props;
const moreProps = {
onRemoveBtnClick: this.removeFileClickHandler.bind(this),
onRenameBtnClick: this.renameFileClickHandler.bind(this),
onNameInputBlur: this.renameFile.bind(this),
onNameInputKeyUp: this.renameFileNameInputKeyUpHandler.bind(this),
fileBeingRenamed: this.state.fileBeingRenamed
};
return (
<div
class="sidebar"
onDragOver={this.dragOverHandler.bind(this)}
onDrop={this.dropHandler.bind(this)}
>
<div class="flex jc-sb" style="padding: 5px 4px">
Files
<div class="flex flex-v-center">
<button
type="button"
class="btn--dark"
onClick={this.addFileButtonClickHandler.bind(this)}
>
<svg
viewBox="0 0 24 24"
style="vertical-align:middle;width:14px;height:14px"
>
<path d="M13,9H18.5L13,3.5V9M6,2H14L20,8V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V4C4,2.89 4.89,2 6,2M11,15V12H9V15H6V17H9V20H11V17H14V15H11Z" />
</svg>
</button>
<button
type="button"
class="btn--dark"
onClick={this.addFolderButtonClickHandler.bind(this)}
>
<svg
viewBox="0 0 24 24"
style="vertical-align:middle;width:14px;height:14px"
>
<path d="M10,4L12,6H20A2,2 0 0,1 22,8V18A2,2 0 0,1 20,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10M15,9V12H12V14H15V17H17V14H20V12H17V9H15Z" />
</svg>
</button>
</div>
</div>
{this.state.isAddingFile || this.state.isAddingFolder ? (
<div>
<input
type="text"
ref={el => {
el &&
setTimeout(() => {
el.focus();
}, 1);
}}
onBlur={this.addFile.bind(this)}
onKeyUp={this.newFileNameInputKeyDownHandler.bind(this)}
/>
</div>
) : null}
{files.map(
file =>
file.isFolder ? (
<Folder {...this.props} {...moreProps} file={file} />
) : (
<File {...this.props} {...moreProps} file={file} />
)
)}
</div>
);
}
}

View File

@ -38,27 +38,58 @@ emmet(CodeMirror);
export default class UserCodeMirror extends Component {
componentDidMount() {
this.initEditor();
this.textarea.parentNode.querySelector(
'.CodeMirror'
).style.fontSize = `${parseInt(this.props.prefs.fontSize, 10)}px`;
}
shouldComponentUpdate() {
shouldComponentUpdate(nextProps) {
if (nextProps.prefs !== this.props.prefs) {
const { prefs } = nextProps;
console.log('updating CM prefs', prefs);
this.cm.setOption('indentWithTabs', prefs.indentWith !== 'spaces');
this.cm.setOption(
'blastCode',
prefs.isCodeBlastOn ? { effect: 2, shake: false } : false
);
this.cm.setOption('theme', prefs.editorTheme);
this.cm.setOption('indentUnit', +prefs.indentSize);
this.cm.setOption('tabSize', +prefs.indentSize);
this.cm.setOption('keyMap', prefs.keymap);
this.cm.setOption('lineWrapping', prefs.lineWrap);
if (this.textarea) {
this.textarea.parentNode.querySelector(
'.CodeMirror'
).style.fontSize = `${parseInt(prefs.fontSize, 10)}px`;
}
this.cm.refresh();
}
return false;
}
initEditor() {
const options = this.props.options;
const { options, prefs } = this.props;
this.cm = CodeMirror.fromTextArea(this.textarea, {
mode: options.mode,
lineNumbers: true,
lineWrapping: true,
lineWrapping: !!prefs.lineWrap,
autofocus: options.autofocus || false,
autoCloseBrackets: true,
autoCloseTags: true,
matchBrackets: true,
matchTags: options.matchTags || false,
tabMode: 'indent',
keyMap: 'sublime',
theme: 'monokai',
keyMap: prefs.keyMap || 'sublime',
theme: prefs.editorTheme || 'monokai',
lint: !!options.lint,
tabSize: 2,
tabSize: +prefs.indentSize || 2,
indentWithTabs: prefs.indentWith !== 'spaces',
indentUnit: +prefs.indentSize,
foldGutter: true,
styleActiveLine: true,
gutters: options.gutters || [],
@ -91,7 +122,7 @@ export default class UserCodeMirror extends Component {
const input = $('[data-setting=indentWith]:checked');
if (
!editor.somethingSelected() &&
(!input || input.value === 'spaces')
(!prefs.indentWith || prefs.indentWith === 'spaces')
) {
// softtabs adds spaces. This is required because by default tab key will put tab, but we want
// to indent with spaces if `spaces` is preferred mode of indentation.
@ -111,10 +142,11 @@ export default class UserCodeMirror extends Component {
this.cm.addKeyMap({
'Ctrl-Space': 'autocomplete'
});
if (!options.noAutocomplete) {
this.cm.on('inputRead', (editor, input) => {
this.cm.on('inputRead', (editor, input) => {
// Process further If this has autocompletition on and also the global
// autocomplete setting is on.
if (!this.props.options.noAutocomplete && this.props.prefs.autoComplete) {
if (
!this.props.prefs.autoComplete ||
input.origin !== '+input' ||
input.text[0] === ';' ||
input.text[0] === ',' ||
@ -125,8 +157,8 @@ export default class UserCodeMirror extends Component {
CodeMirror.commands.autocomplete(this.cm, null, {
completeSingle: false
});
});
}
}
});
this.props.onCreation(this.cm);
}

View File

@ -2,9 +2,10 @@
*/
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';
@ -21,6 +22,13 @@ import {
getCompleteHtml,
getFilenameFromUrl
} from '../utils';
import {
linearizeFiles,
assignFilePaths,
getFileFromPath,
removeFileAtPath,
doesFileExistInFolder
} from '../fileUtils';
import { itemService } from '../itemService';
import '../db';
import { Notifications } from './Notifications';
@ -194,7 +202,7 @@ export default class App extends Component {
this.createNewItem();
}
Object.assign(this.state.prefs, result);
this.setState({ prefs: this.state.prefs });
this.setState({ prefs: { ...this.state.prefs } });
this.updateSetting();
});
@ -231,6 +239,22 @@ export default class App extends Component {
});
}
incrementUnsavedChanges() {
this.setState({ unsavedEditCount: this.state.unsavedEditCount + 1 });
if (
this.state.unsavedEditCount % UNSAVED_WARNING_COUNT === 0 &&
this.state.unsavedEditCount >= 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');
});
}
}
updateProfileUi() {
if (this.state.user) {
document.body.classList.add('is-logged-in');
@ -264,9 +288,9 @@ export default class App extends Component {
alertsService.add(`"${sourceItem.title}" was forked`);
trackEvent('fn', 'itemForked');
}
createNewItem() {
var d = new Date();
this.setCurrentItem({
createNewItem(isFileMode = false) {
const d = new Date();
let item = {
title:
'Untitled ' +
d.getDate() +
@ -276,12 +300,38 @@ export default class App extends Component {
d.getHours() +
':' +
d.getMinutes(),
html: '',
css: '',
js: '',
externalLibs: { js: '', css: '' },
layoutMode: this.state.currentLayoutMode
}).then(() => this.refreshEditor());
createdOn: +d,
content: ''
};
if (isFileMode) {
item = {
...item,
files: assignFilePaths([
{ name: 'index.html', content: '' },
{
name: 'styles',
isFolder: true,
children: [{ name: 'style.css', content: '' }]
},
{ name: 'script.js', content: '' },
{
name: 'tempo',
isFolder: true,
children: [{ name: 'main.css', 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) {
@ -317,10 +367,12 @@ export default class App extends Component {
setCurrentItem(item) {
const d = deferred();
// TODO: remove later
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;
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);
@ -470,7 +522,9 @@ export default class App extends Component {
isKeyboardShortcutsModalOpen: !this.state.isKeyboardShortcutsModalOpen
});
trackEvent('ui', 'showKeyboardShortcutsShortcut');
} else if (event.keyCode === 27) {
} else if (event.keyCode === 27 && event.target.tagName !== 'INPUT') {
// We might be listening on keydown for some input inside the app. In that case
// we don't want this to trigger which in turn focuses back the last editor.
this.closeSavedItemsPane();
}
});
@ -684,21 +738,17 @@ export default class App extends Component {
this.setState({ currentItem: item });
}
onCodeChange(type, code, isUserChange) {
this.state.currentItem[type] = code;
if (this.state.currentItem.files) {
linearizeFiles(this.state.currentItem.files).map(file => {
if (file.name === type.name) {
file.content = code;
}
});
} else {
this.state.currentItem[type] = code;
}
if (isUserChange) {
this.setState({ unsavedEditCount: this.state.unsavedEditCount + 1 });
if (
this.state.unsavedEditCount % UNSAVED_WARNING_COUNT === 0 &&
this.state.unsavedEditCount >= 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');
});
}
this.incrementUnsavedChanges();
}
if (this.state.prefs.isJs13kModeOn) {
// Throttling codesize calculation
@ -1140,6 +1190,10 @@ export default class App extends Component {
this.createNewItem();
this.setState({ isCreateNewModalOpen: false });
}
blankFileTemplateSelectHandler() {
this.createNewItem(true);
this.setState({ isCreateNewModalOpen: false });
}
templateSelectHandler(template) {
fetch(`templates/template-${template.id}.json`)
@ -1149,10 +1203,97 @@ export default class App extends Component {
});
this.setState({ isCreateNewModalOpen: false });
}
addFileHandler(fileName, isFolder) {
let newEntry = { name: fileName, content: '' };
if (isFolder) {
newEntry = {
...newEntry,
isFolder: true,
children: [],
isCollapsed: true
};
}
let currentItem = {
...this.state.currentItem,
files: [...this.state.currentItem.files, newEntry]
};
assignFilePaths(currentItem.files);
this.setState({ currentItem });
this.incrementUnsavedChanges();
}
removeFileHandler(filePath) {
const currentItem = {
...this.state.currentItem,
files: [...this.state.currentItem.files]
};
removeFileAtPath(currentItem.files, filePath);
this.setState({ currentItem });
this.incrementUnsavedChanges();
}
renameFileHandler(oldFilePath, newFileName) {
const currentItem = {
...this.state.currentItem,
files: [...this.state.currentItem.files]
};
const { file } = getFileFromPath(currentItem.files, oldFilePath);
file.name = newFileName;
assignFilePaths(currentItem.files);
this.setState({ currentItem });
this.incrementUnsavedChanges();
}
fileDropHandler(sourceFilePath, destinationFolder) {
let { currentItem } = this.state;
const { file } = getFileFromPath(currentItem.files, sourceFilePath);
if (doesFileExistInFolder(destinationFolder, file.name)) {
alert(
`File with name "${
file.name
}" already exists in the destination folder.`
);
return;
}
if (file) {
destinationFolder.children.push(file);
removeFileAtPath(currentItem.files, sourceFilePath);
currentItem = {
...currentItem,
files: [...currentItem.files]
};
assignFilePaths(currentItem.files);
this.setState({ currentItem });
this.incrementUnsavedChanges();
}
}
folderSelectHandler(folder) {
// Following will make the change in the existing currentItem
folder.isCollapsed = !folder.isCollapsed;
const currentItem = {
...this.state.currentItem,
files: [...this.state.currentItem.files]
};
this.setState({
currentItem
});
}
getRootClasses() {
const classes = [];
if (this.state.currentItem && this.state.currentItem.files) {
classes.push('is-file-mode');
}
return classes.join(' ');
}
render() {
return (
<div>
<div class={this.getRootClasses()}>
<div class="main-container">
<MainHeader
externalLibCount={this.state.externalLibCount}
@ -1169,18 +1310,37 @@ export default class App extends Component {
titleInputBlurHandler={this.titleInputBlurHandler.bind(this)}
user={this.state.user}
unsavedEditCount={this.state.unsavedEditCount}
isFileMode={this.state.currentItem && this.state.currentItem.files}
/>
<ContentWrap
currentLayoutMode={this.state.currentLayoutMode}
currentItem={this.state.currentItem}
onCodeChange={this.onCodeChange.bind(this)}
onCodeSettingsChange={this.onCodeSettingsChange.bind(this)}
onCodeModeChange={this.onCodeModeChange.bind(this)}
onRef={comp => (this.contentWrap = comp)}
prefs={this.state.prefs}
onEditorFocus={this.editorFocusHandler.bind(this)}
onSplitUpdate={this.splitUpdateHandler.bind(this)}
/>
{this.state.currentItem && this.state.currentItem.files ? (
<ContentWrapFiles
currentItem={this.state.currentItem}
onCodeChange={this.onCodeChange.bind(this)}
onCodeSettingsChange={this.onCodeSettingsChange.bind(this)}
onCodeModeChange={this.onCodeModeChange.bind(this)}
onRef={comp => (this.contentWrap = comp)}
prefs={this.state.prefs}
onEditorFocus={this.editorFocusHandler.bind(this)}
onSplitUpdate={this.splitUpdateHandler.bind(this)}
onAddFile={this.addFileHandler.bind(this)}
onRemoveFile={this.removeFileHandler.bind(this)}
onRenameFile={this.renameFileHandler.bind(this)}
onFileDrop={this.fileDropHandler.bind(this)}
onFolderSelect={this.folderSelectHandler.bind(this)}
/>
) : (
<ContentWrap
currentLayoutMode={this.state.currentLayoutMode}
currentItem={this.state.currentItem}
onCodeChange={this.onCodeChange.bind(this)}
onCodeSettingsChange={this.onCodeSettingsChange.bind(this)}
onCodeModeChange={this.onCodeModeChange.bind(this)}
onRef={comp => (this.contentWrap = comp)}
prefs={this.state.prefs}
onEditorFocus={this.editorFocusHandler.bind(this)}
onSplitUpdate={this.splitUpdateHandler.bind(this)}
/>
)}
<Footer
prefs={this.state.prefs}
@ -1337,6 +1497,9 @@ export default class App extends Component {
show={this.state.isCreateNewModalOpen}
closeHandler={() => this.setState({ isCreateNewModalOpen: false })}
onBlankTemplateSelect={this.blankTemplateSelectHandler.bind(this)}
onBlankFileTemplateSelect={this.blankFileTemplateSelectHandler.bind(
this
)}
onTemplateSelect={this.templateSelectHandler.bind(this)}
/>

109
src/fileUtils.js Normal file
View File

@ -0,0 +1,109 @@
import { deferred } from './deferred';
const esprima = require('esprima');
/**
* Returns a linear file list from a nested file strcuture.
* It excludes the folders from the returned list.
* @param {array} files Nested file structure
*/
export function linearizeFiles(files) {
function reduceToLinearFiles(files) {
return files.reduce((list, currentFile) => {
if (currentFile.isFolder) {
return [...list, ...reduceToLinearFiles(currentFile.children)];
} else {
return [...list, currentFile];
}
}, []);
}
return reduceToLinearFiles(files);
}
/**
* Recursively iterates and assigns the `path` property to the files in passed files
* array.
* @param {array} files files structure for an item
* @param {string} parentPath Parent path to prefix with all processed files
*/
export function assignFilePaths(files, parentPath = '') {
files.forEach(file => {
file.path = parentPath ? `${parentPath}/${file.name}` : file.name;
if (file.isFolder) {
assignFilePaths(
file.children,
parentPath ? `${parentPath}/${file.name}` : file.name
);
}
});
return files;
}
/**
* Returns the file object and it's index that is direct child of passed files array with name as passed fileName.
* If not found, returns -1
* @param {array} files files structure for an item
* @param {string} fileName File/folder name
*/
export function getChildFileFromName(files, fileName) {
const index = files.findIndex(file => file.name === fileName);
return { index, file: files[index] };
}
/**
* Returns the file object and it's index in its parent for the passed path.
* If not found, returns {index:-1}
* @param {array} files files structure for an item
* @param {string} path Path of file to search
*/
export function getFileFromPath(files, path) {
let currentFolder = files;
const pathPieces = path.split('/');
while (pathPieces.length > 1) {
let folderName = pathPieces.shift();
currentFolder = getChildFileFromName(currentFolder, folderName).file
.children;
}
// now we should be left with just one value in the pathPieces array - the actual file name
return getChildFileFromName(currentFolder, pathPieces[0]);
}
/**
* Returns the file object and it's index in its parent for the passed path.
* If not found, returns {index:-1}
* @param {array} files files structure for an item
* @param {string} path Path of file to search
*/
export function removeFileAtPath(files, path) {
let currentFolder = files;
const pathPieces = path.split('/');
while (pathPieces.length > 1) {
let folderName = pathPieces.shift();
currentFolder = getChildFileFromName(currentFolder, folderName).file
.children;
}
// now we should be left with just one value in the pathPieces array - the actual file name
const { index } = getChildFileFromName(currentFolder, pathPieces[0]);
currentFolder.splice(index, 1);
}
/**
* Checks if a file with same name exists in the passed folder.
* @param {object} folder Folder to search in
* @param {string} fileName File name to search for
*/
export function doesFileExistInFolder(folder, fileName) {
const details = getChildFileFromName(folder.children, fileName);
return !!details.file;
}
/**
* Returns parent path of passed path
* @param {string} path Path of file to find parent of
*/
export function getParentPath(path) {
const pathPieces = path.split('/');
if (pathPieces.length > 1) {
return pathPieces.slice(0, -1).join('/');
}
return '';
}

View File

@ -67,9 +67,9 @@
</style>
</head>
<body>
<!-- SCRIPT-TAGS -->
<%= htmlWebpackPlugin.options.ssr({
<body>
<!-- SCRIPT-TAGS -->
<%= htmlWebpackPlugin.options.ssr({
url: '/'
}) %>
<script defer src="<%= htmlWebpackPlugin.files.chunks['bundle'].entry %>"></script>

View File

@ -19,7 +19,6 @@
if (
'serviceWorker' in navigator &&
location.protocol === 'https:' &&
document.cookie.indexOf('wmdebug') === -1 &&
location.href.indexOf('chrome-extension://') === -1 &&
location.href.indexOf('192.168') === -1
) {

40
src/service-worker.js Normal file
View File

@ -0,0 +1,40 @@
self.addEventListener('fetch', function(event) {
// console.log("fetch event", event.request.url);
if (event.request.url.indexOf('/user/') !== -1) {
event.respondWith(
caches.match(event.request).then(function(response) {
// console.log("responding with ", response);
if (response !== undefined) {
return response;
}
})
);
}
});
function getContentType(url) {
if (url.match(/\.html$/)) {
return 'text/html; charset=UTF-8';
} else if (url.match(/\.css$/)) {
return 'text/css; charset=UTF-8';
}
if (url.match(/\.js$/)) {
return 'application/javascript; charset=UTF-8';
}
}
self.addEventListener('message', function(e) {
// console.log("message aya sw main", e.data);
caches.open('v1').then(function(cache) {
for (url in e.data) {
// console.log('Received data', url, e.data[url])
cache.put(
url,
new Response(e.data[url], {
headers: {
'Content-Type': getContentType(url)
}
})
);
}
});
});

View File

@ -39,8 +39,9 @@ textarea {
border-bottom-color: rgba(255, 255, 255, 0.17);
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.2);
border-radius: 3px;
padding: 10px;
padding: 5px 10px;
box-sizing: border-box;
max-width: 100%;
}
[role='button'] {
@ -96,6 +97,9 @@ button {
.flex-shrink-0 {
flex-shrink: 0;
}
.jc-sb {
justify-content: space-between;
}
.fr {
float: right;
@ -383,6 +387,10 @@ body:not(.light-version).overlay-visible .main-container {
width: auto;
}
.is-file-mode .content-wrap {
flex-direction: row;
}
/* All vertical layout */
.layout-5 .code-side {
@ -447,6 +455,9 @@ body:not(.light-version).overlay-visible .main-container {
height: auto;
width: 33%;
}
.is-file-mode .code-wrap {
flex-basis: 100%;
}
.code-wrap__header {
display: flex;
@ -650,6 +661,8 @@ body > #demo-frame {
.btn--dark > svg {
fill: #9297b3;
}
.btn--dark > svg:not(:last-child) {
margin-right: 4px;
}
@ -658,9 +671,13 @@ body > #demo-frame {
}
.btn--dark:hover {
border-color: rgba(146, 151, 179, 0.5);
background: #9297b3;
color: #111;
/* border-color: rgba(146, 151, 179, 0.5); */
}
.btn--dark:hover > svg {
fill: #111;
}
.main-header__btn-wrap > .is-loading {
pointer-events: none;
opacity: 0.4;
@ -1593,6 +1610,72 @@ body:not(.is-app) .show-when-app {
margin-left: -115px;
}
.sidebar {
background-color: #1e1e2c;
height: 100%;
}
.sidebar__file {
color: #bbb;
background: transparent;
border: none;
width: 100%;
text-align: left;
display: flex;
padding: 5px 5px;
align-items: center;
justify-content: space-between;
position: relative;
cursor: pointer;
}
/* 1st level nesting */
.sidebar__folder-wrap .sidebar__file {
padding-left: 1rem;
}
/* 2nd level nesting */
.sidebar__folder-wrap .sidebar__folder-wrap .sidebar__file {
padding-left: 2rem;
}
/* 3rd level nesting */
.sidebar__folder-wrap
.sidebar__folder-wrap
.sidebar__folder-wrap
.sidebar__file {
padding-left: 3rem;
}
.sidebar__file:hover,
.sidebar__file:focus {
background-color: rgba(255, 255, 255, 0.05);
}
.sidebar__file.selected {
color: white;
background-color: rgba(255, 255, 255, 0.1);
}
.sidebar__file-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
.sidebar__file-options {
position: absolute;
right: 0;
visibility: hidden;
}
.sidebar__file:hover .sidebar__file-options,
.sidebar__file:focus .sidebar__file-options,
.sidebar__file:focus-within .sidebar__file-options {
visibility: visible;
}
.sidebar__folder-wrap[data-collapsed='true'] {
display: none;
}
.is-file-mode .hide-in-file-mode {
display: none !important;
}
@media screen and (max-width: 600px) {
body {
font-size: 70%;

View File

@ -0,0 +1,96 @@
// See: https://github.com/mzgoddard/preact-render-spy
import { shallow, deep } from 'preact-render-spy';
import {
assignFilePaths,
getFileFromPath,
removeFileAtPath,
getParentPath
} from '../fileUtils';
function getNestedFiles() {
return [
{ name: 'index.html' },
{
name: 'styles',
isFolder: true,
children: [{ name: 'main.css' }, { name: 'style.css' }]
},
{ name: 'script.js' }
];
}
describe('assignFilePaths', () => {
test('should assign path on linear file system', () => {
const files = [{ name: 'index.html' }, { name: 'main.css' }];
assignFilePaths(files);
expect(files[0].path).toBe('index.html');
expect(files[1].path).toBe('main.css');
});
test('should assign path on nested file system', () => {
const files = getNestedFiles();
assignFilePaths(files);
expect(files[0].path).toBe('index.html');
expect(files[1].children[0].path).toBe('styles/main.css');
});
});
describe('getFileFromPath', () => {
test('should return file and correct index', () => {
const files = getNestedFiles();
assignFilePaths(files);
const { index, file } = getFileFromPath(files, 'index.html');
expect(index).toBe(0);
expect(file).toBe(files[index]);
});
test('should return empty object for non-existent path', () => {
const files = getNestedFiles();
assignFilePaths(files);
const { index, file } = getFileFromPath(files, 'style.css');
expect(index).toBe(-1);
expect(file).toBe(undefined);
});
test('should return file and correct index for a nested file', () => {
const files = getNestedFiles();
assignFilePaths(files);
const { index, file } = getFileFromPath(files, 'styles/style.css');
expect(index).toBe(1);
expect(file).toBe(files[1].children[index]);
});
});
describe('removeFileAtPath', () => {
test('should remove direct child file', () => {
const files = getNestedFiles();
assignFilePaths(files);
expect(files.length).toBe(3);
removeFileAtPath(files, 'index.html');
expect(files.length).toBe(2);
expect(files[0].name).toBe('styles');
});
test('should remove a nested file', () => {
const files = getNestedFiles();
assignFilePaths(files);
expect(files.length).toBe(3);
removeFileAtPath(files, 'styles/style.css');
expect(files.length).toBe(3);
expect(files[1].children.length).toBe(1);
expect(files[0].name).toBe('index.html');
expect(files[2].name).toBe('script.js');
});
});
describe('getParentPath', () => {
test('should return correct parent path for root file', () => {
expect(getParentPath('style.css')).toBe('');
});
test('should return correct parent path for nested file', () => {
expect(getParentPath('styles/style.css')).toBe('styles');
expect(getParentPath('js/plugins/main.js')).toBe('js/plugins');
expect(getParentPath('js/plugins/readme')).toBe('js/plugins');
});
});