⚄︎perchance
👥︎community
🌈hub
📚︎tutorial
📦︎resources
🎲︎generators
⊕︎new
🔑︎login
✏️︎edit
`, "single-equals-in-dynamic-odds": `
Warning: The dynamic odds notation on line number ###lineNumber### (in ###generatorNameText### generator's code) has a single equals sign instead of a double-equals sign. A single equals sign is used to put a value into a variable - for example age = 10
means "put the value 10
into the age
variable" whereas age == 10
means "is age
equal to 10
?".
`, "single-equals-in-if-else-condition": `
Warning: The if/else condition on line number ###lineNumber### (in ###generatorNameText### generator's code) has a single equals sign instead of a double-equals sign. A single equals sign is used to put a value into a variable - for example age = 10
means "put the value 10
into the age
variable" whereas age == 10
means "is age
equal to 10
?".
`, "square-brackets-wrapping-variable-name-within-square-block": `
Warning: On line number ###lineNumber### (in ###generatorNameText### generator's code), you've got a square block - i.e. some square brackets with some stuff inside. Nothing wrong with that. But inside that square block it looks like you've got another square block? Whenever you're writing stuff inside a square block, you can refer to variable and list name
directly - no need to put them inside square blocks. So instead of [[noun].pluralForm]
, you'd just write [noun.pluralForm]
, and instead of [[characterAge] + 3]
, you'd just write [characterAge + 3]
, and instead of ^[[age] < 10]
, you'd just write ^[age < 10]
, and instead of [[age] > 60 ? [oldDescription] : [youngDescription]]
, you'd just write [age > 60 ? oldDescription : youngDescription]
. You should use normal parentheses to group calculations and conditions, like this: [(age + 1) * height]
and this: [isMammal || (isReptile && isBrown)]
. In fact, if you wrap list/variable names in square brackets when you're already inside a square block then this can sometimes cause all sorts of sneaky errors that are hard to "debug". Square brackets
"legally" be used within square brackets in some circumstances, but they have a special meaning (learn more about this on the examples page - especially the "Dynamic Sub-list Referencing" section). One final note: It's okay to use square brackets within square brackets if the inner square brackets are themselves within double-quotes, like so: [n == 1 ? "[prefix]er" : "[prefix]ers"]
.
`, "top-level-list-name-same-as-html-element-id": `
Warning: It looks like you have a list name on line number ###lineNumber### that has the same name as an element's id="..."
attribute? If so, note that these will conflict with one another and potentially cause errors. For example if you had a list called animal
, then you shouldn't have an element like this: <p id="animal">...</p>
`, "duplicate-top-level-list-name": `
Warning: It looks like you have a list name on line number ###lineNumber### (in ###generatorNameText### generator's code) that has the same name as an earlier list/import/variable?
`, }; this.generatorMetaData = undefined; this.toggleOutputEditorWrap = function() { let btn = this.refs.outputEditorRefButton; let state = this.outputTemplateEditor.getOption('lineWrapping'); if(state) { btn.innerText = "wrap"; } else { btn.innerText = "unwrap"; } this.outputTemplateEditor.setOption('lineWrapping', !state); }; this.toggleCodeEditorWrap = function() { let btn = this.refs.codeEditorWrapRefButton; let state = this.modelTextEditor.getOption('lineWrapping'); if(state) { btn.innerText = "wrap"; } else { btn.innerText = "unwrap"; } // record cursor position: let cursorLine = this.modelTextEditor.getCursor().line; let cursorTop = this.modelTextEditor.cursorCoords().top - this.modelTextEditor.display.wrapper.getBoundingClientRect().top; this.modelTextEditor.setOption('lineWrapping', !state); // recover scroll to cursor position: this.modelTextEditor.scrollIntoView({line:cursorLine, char:0}, cursorTop); }; this.allListsFolded = false; this.toggleCodeEditorFold = function() { let btn = this.refs.codeEditorFoldRefButton; let state = this.modelTextEditor.getOption('lineWrapping'); this.allListsFolded ? CodeMirror.commands.unfoldAll(this.modelTextEditor) : CodeMirror.commands.foldAll(this.modelTextEditor); this.allListsFolded = !this.allListsFolded; btn.innerText = this.allListsFolded ? "unfold" : "fold"; }; this.outputIframe = this.root.querySelector("#output iframe"); this.refs.autoReloadCheckbox.checked = this.autoReload; window.addEventListener("message", async (e) => { let origin = e.origin || e.originalEvent.origin; if(origin !== "https://null.perchance.org" && origin !== `https://${window.generatorPublicId}.perchance.org`) return; if(e.data.type === "evaluateTextResponse" && typeof e.data.text === "string" && typeof e.data.callerId === "string") { let resolveMap = this.evaluateTextResolveMap; if(resolveMap[e.data.callerId]) { resolveMap[e.data.callerId](e.data.text); delete resolveMap[e.data.callerId]; } } if(e.data.type === "requestOutputUpdate") { // for 'hard reload' without having to first save data to server // user clicks reload button which triggers a fullRefresh, but it adds __initWithDataFromParentWindow=1 to the URL, which causes the outputIframeContent.html code to not use the data embedded in the HTML, and instead request this parent frame for the 'fresh' data await window.mostRecentFullRefreshUpdateDependenciesPromise; // so dependency stuff can load in parrallel with iframe for __initWithDataFromParentWindow fullRefresh this.updateOutputRequest(); } if(e.data.type === "saveKeyboardShortcut") { app.handleIframeSaveRequest(); } // if(e.data.type === "finishedLoading") { // window.perchanceOutputIframeFinishedFirstLoad = true; // // initialPageLoadSpinner.style.display = "none"; // outputLoadSpinner.style.display = "none"; // } if(e.data.type === "finishedLoadingIncludingMetaData" || e.data.type === "failedToLoadDueToGeneratorErrors") { if(e.data.imports) { // <-- need to check because failedToLoadDueToGeneratorErrors doesn't give us imports // we need to check if there are new imports before resolving the hard reload stuff because the saveGenerator function uses a hard reload before saving (which triggers these messages and resolves below), and we want to make sure we save the generator with all its up-to-date dependencies included. let wereNewDeps = await window.lastImportsUpdateDependencyCheckPromise; if(wereNewDeps) return; // don't want to resolve yet - the importsUpdate handler will trigger a second hard reload } for(let resolver of window.outputIframeHardReloadFinishedResolvers) { resolver(); } window.outputIframeHardReloadFinishedResolvers = []; } if(e.data.type === "importsUpdate" && Array.isArray(e.data.imports) && e.data.imports.filter(i => typeof i !== 'string').length === 0) { await window.thisIsNotAnOldCachedPagePromise; // <-- wait until we've confirmed that this is the latest version of this generator. this prevents a weird race condition where an import update will happen fast during page load and beat the cache bust, which cause an auto-save (importsUpdates can do this), and overwrites the new code with old code. // (note that the above promise won't resolve unless it's true) window.lastImportsUpdateDependencyCheckPromise = this.updateDependencies(e.data.imports); let wereNewDeps = await window.lastImportsUpdateDependencyCheckPromise; // <-- this actually downloads new dependencies (**if needed**, else returns false) if(wereNewDeps) { // we only update if there were new dependencies otherwise we will get into infinite loops // (due to the way I've handled removing dependencies (I keep the cached still)) TODO: this whole // imports-update part is terrible and needs re-writing to make it simple and understandable. // Wouldn't take toooo much work. this.updateOutput(true); // <-- this triggers iframe updateOutput function. `true` => fullRefresh, i.e. fully reload the iframe with __initWithDataFromParentWindow app.generatorEditedCallback({imports:e.data.imports}); } else if(app.data.dependencies.length > e.data.imports.length) { // if dependency was *dropped*, no need to update the output - just need to update the data: app.generatorEditedCallback({imports:e.data.imports}); } } if(e.data.type === "metaUpdate") { if(e.data._validation.generatorName !== app.data.generator.name) return; // <-- trying to stop weird Google crawler page title bug // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ // WARNING WARNING WARNING WARNING WARNING WARNING WARNING: If you change this at all, make sure to do a thorough check for XSS bugs. Saved meta info must be sanitised ON THE SERVER during rendering. // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ let title = undefined; let description = undefined; let image = undefined; let dynamic = undefined; // if(typeof e.data.html === "string") { // if(e.data.html.length < 100_000 && window.shouldLiftGeneratorHtmlFromEmbed) { // window.document.querySelector("#generator-description").innerHTML = DOMPurify.sanitize(e.data.html, {ALLOWED_TAGS: ['h1', 'h2', 'h3', 'h4', 'p', 'div', 'pre', 'button', 'i', 'b', 'a', 'ul', 'li', 'br', 'ol', 'hr'], ALLOWED_ATTR: ['href']}); // if(!window.generatorCanLinkWithRelFollow) window.document.querySelectorAll("#generator-description a").forEach(a => a.rel="nofollow"); // } // } if(typeof e.data.title === "string") { title = e.data.title.slice(0, 500); // MUST be sanitised on the server during rendering } if(typeof e.data.description === "string") { description = e.data.description.slice(0, 3000); // MUST be sanitised on the server during rendering } if(typeof e.data.image === "string") { image = e.data.image.slice(0, 500); // MUST be sanitised on the server during rendering } if(typeof e.data.dynamic === "string") { dynamic = e.data.dynamic.slice(0, 20000); } this.generatorMetaData = {title,image,description, dynamic}; } if(e.data.type === "codeWarningsUpdate") { let validWarningsCount = 0; this.refs.warningsModalOpenButton.style.display = "none"; this.refs.warningsWrapper.innerHTML = ""; let warningsHTML = ""; for(let warning of e.data.warnings) { if(typeof warning.lineNumber !== "number" || typeof warning.warningId !== "string" || /[^a-z0-9\-]/.test(warning.generatorName)) return; let warningTemplate = warnings[warning.warningId]; if(!warningTemplate) { console.error("invalid warning id?"); continue; } warningTemplate = warningTemplate.replace(/###lineNumber###/g, warning.lineNumber); let generatorNameText; if(warning.generatorName === window.location.pathname.slice(1)) generatorNameText = "this"; else generatorNameText = "the "+warning.generatorName+""; warningTemplate = warningTemplate.replace(/###generatorNameText###/g, generatorNameText); warningTemplate = warningTemplate.replace(/ \(in this<\/b> generator's code\)/g, ""); // hackily remove this, since it actually makes it more confusing for newbies I think. warningsHTML += warningTemplate; validWarningsCount++; if(validWarningsCount > 10) break; } if(validWarningsCount > 0) { this.refs.warningsModalOpenButton.style.display = "inline-block"; this.refs.warningsWrapper.innerHTML = warningsHTML; this.refs.warningsModal.querySelector(".modal-body").scrollTop = 0; } } // if(e.data.type === "requestFullscreen") { // if(this.outputIframe.requestFullscreen) this.outputIframe.requestFullscreen(); // else if(this.outputIframe.webkitRequestFullscreen) this.outputIframe.webkitRequestFullscreen(); // else if(this.outputIframe.mozRequestFullscreen) this.outputIframe.mozRequestFullscreen(); // else if(this.outputIframe.msRequestFullscreen) this.outputIframe.msRequestFullscreen(); // } // // if(e.data.type === "exitFullscreen") { // if(this.outputIframe.exitFullscreen) this.outputIframe.exitFullscreen(); // else if(this.outputIframe.webkitExitFullscreen) this.outputIframe.webkitExitFullscreen(); // else if(this.outputIframe.mozCancelFullscreen) this.outputIframe.mozCancelFullscreen(); // else if(this.outputIframe.msExitFullscreen) this.outputIframe.msExitFullscreen(); // } }); // codemirror settings CodeMirror.keyMap.default["Shift-Tab"] = "indentLess"; CodeMirror.keyMap.default["Tab"] = "indentMore"; this.root.querySelector("#input").value = this.root.querySelector("#input").value.replace(/\n[\t ]+/g, (match) => match.replace(/\\t/g, " ")); // I'm reluctant to mess with tabs in the HTML editor because they are significant in e.g. , and I'm not sure if replacing with entity () would actually work in all cases. Should be solved for all future generators anyway (via pasting intercept code below). /*this.modelTextEditor = CodeMirror.fromTextArea(this.root.querySelector("#input"), { lineNumbers: true, foldGutter: true, extraKeys: { //"Ctrl-Q": cm => cm.foldCode(cm.getCursor()), //"Ctrl-Y": cm => CodeMirror.commands.foldAll(cm), //"Ctrl-I": cm => CodeMirror.commands.unfoldAll(cm), }, gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"], tabSize: 2, indentUnit: 2, indentWithTabs: false, matchBrackets: true, mode: localStorage.codeMirrorMode || "simplemode", styleActiveLine: true, //mode: "text", lineWrapping:false, //theme: "monokai", keyMap: "sublime", });*/ let systemIsInDarkMode = !!(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); this.modelTextEditor = CodeMirror.fromTextArea(this.root.querySelector("#input"), { lineNumbers: true, foldGutter: true, extraKeys: { //"Ctrl-Q": cm => cm.foldCode(cm.getCursor()), //"Ctrl-Y": cm => CodeMirror.commands.foldAll(cm), //"Ctrl-I": cm => CodeMirror.commands.unfoldAll(cm), }, gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"], tabSize: 2, indentUnit: 2, indentWithTabs: false, matchBrackets: true, mode: "perchancelists", styleActiveLine: true, lineWrapping:false, theme: systemIsInDarkMode ? "one-dark" : "one-light", keyMap: "sublime", highlightSelectionMatches: {showToken: /\w/, annotateScrollbar: true}, }); // hacky fix for a bug where if you type two consecutive backticks, the `perchancelists` mode's JS state gets messed up for some reason. // we just retokenize when backtick is typed. let backtickHighlightRetokenizeTimeout = null; this.modelTextEditor.on("beforeChange", function(cm, changeObj) { if(changeObj.text.some(line => line.includes('`'))) { clearTimeout(backtickHighlightRetokenizeTimeout); backtickHighlightRetokenizeTimeout = setTimeout(() => cm.setOption("mode", "perchancelists"), 200); } }); this.outputTemplateEditor = CodeMirror.fromTextArea(this.root.querySelector("#template"), { // lineNumbers: true, // foldGutter: true, // gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"], mode: {name: "htmlmixed"}, selectionPointer: true, tabSize: 2, indentUnit: 2, indentWithTabs: false, lineWrapping:false, styleActiveLine: true, theme: systemIsInDarkMode ? "one-dark" : "one-light", keyMap: "sublime", highlightSelectionMatches: {showToken: /\w/, annotateScrollbar: true}, }); if(localStorage.editorFontSize && !isNaN(Number(localStorage.editorFontSize))) { let size = Number(localStorage.editorFontSize); let style = document.createElement("style"); style.innerHTML = `.CodeMirror { font-size: ${size}px !important; line-height: ${size*1.4}px !important; }`; document.head.appendChild(style); } window.editsHaveBeenMadeSincePageLoad = false; // these are also called after the generator is saved, so long as window.lastEditorsChangeTime is the same as the instant that the save operation was initiated. // codemirror will make editor.isClean() return true when it comes back to this state, **including via undo** this.modelTextEditor.markClean(); this.outputTemplateEditor.markClean(); // so the save code can access them for markClean stuff: window.modelTextEditor = this.modelTextEditor; window.outputTemplateEditor = this.outputTemplateEditor; // hide wrap/fold buttons if cursor is near them, or on first line async function updateToolbarVisibility(editor) { await new Promise(r => setTimeout(r, 10)); let editorCtn = editor.getWrapperElement().parentElement; const cursor = editorCtn.querySelector(".CodeMirror-cursor"); const hoverToolbar = editorCtn.querySelector('.code-editor-buttons-ctn') || editorCtn.querySelector('.toggle-wrap-button-html'); if(!cursor || !hoverToolbar) return; const cursorRect = cursor.getBoundingClientRect(); const toolbarRect = hoverToolbar.getBoundingClientRect(); let isNear = cursorRect.top <= toolbarRect.bottom+20 && cursorRect.left >= toolbarRect.left-50 && cursorRect.left <= toolbarRect.right+50; if(editor.getCursor().line === 0) isNear = true; hoverToolbar.style.pointerEvents = isNear ? 'none' : 'auto'; hoverToolbar.style.opacity = isNear ? '0' : '1'; } window.modelTextEditor.on('keyHandled', updateToolbarVisibility); window.outputTemplateEditor.on('keyHandled', updateToolbarVisibility); window.modelTextEditor.getWrapperElement().addEventListener('mouseup', () => updateToolbarVisibility(window.modelTextEditor)); window.outputTemplateEditor.getWrapperElement().addEventListener('mouseup', () => updateToolbarVisibility(window.outputTemplateEditor)); // this is also set in the save handler. they're used as an extra check in doLocalGeneratorBackupIfNeeded to prevent backing up text that is already saved. window.lastModelTextSaved = this.modelTextEditor.getValue(); window.lastOutputTemplateSaved = this.outputTemplateEditor.getValue(); // this is stupid but it's the only way I could get chrome to stop suggesting an email/password in codemirror's search dialogue try { function watchForChildWithClass(target, className, callback) { new MutationObserver(mutations => { mutations.forEach(mutation => { Array.from(mutation.addedNodes).forEach(node => { if(node.nodeType === 1 && node.classList.contains(className)) callback(node); }); }); }).observe(target, { childList: true }); } setTimeout(() => { for(let container of Array.from(document.querySelectorAll(".CodeMirror"))) { watchForChildWithClass(container, "CodeMirror-dialog", element => { for(let inputEl of Array.from(element.querySelectorAll("input"))) { let dummyEl = document.createElement("div"); dummyEl.innerHTML = ``; dummyEl.style.cssText = "opacity:0; pointer-events:none; position:absolute;"; inputEl.parentElement.insertBefore(dummyEl, inputEl); } }); } }, 400); } catch(e) { console.error(e); } // there was some funky business happening with tabs when people *pasted* them. // This replaces all tabs with two spaces (users need to write \t if they want a tab in their text). // EDIT: also just added non-breaking space replacement with normal space, since it was messing with indenting stuff. this.modelTextEditor.on("beforeChange", (cm, change) => { if(change.origin === "paste") { change.update(null, null, change.text.map(line => line.replace(/\t/g, " ").replaceAll(`\u00A0`, " "))); } }); // I took this out because in HTML, some text with to consecutive spaces is different to , and I think the same is true of tabs. So I think this would turn code indents into a bunch of visible whitespace. // this.outputTemplateEditor.on("beforeChange", (cm, change) => { // if(change.origin === "paste") { // change.update(null, null, change.text.map(line => line.replace(/\t/g, ""))); // } // }); // This is hacky, but it seems to work. It's from here: https://codemirror.net/demo/indentwrap.html // It makes it so wrapped text is indented at the same indent level as the line itself. let basePadding = 4; // Only do the indentwrap stuff if there are no tabs (literal `\t` is fine), because otherwise it (for some reason) doesn't work - it messes things up: jsbin.com/tukuximome if(!this.root.querySelector("#template").value.includes("\t")) { let charWidthOutputTemplate = this.outputTemplateEditor.defaultCharWidth(); this.outputTemplateEditor.on("renderLine", function(cm, line, elt) { let off = CodeMirror.countColumn(line.text, null, cm.getOption("tabSize")) * charWidthOutputTemplate; elt.style.textIndent = "-" + off + "px"; elt.style.paddingLeft = (basePadding + off) + "px"; }); } if(!this.root.querySelector("#input").value.includes("\t")) { let charWidthModelText = this.modelTextEditor.defaultCharWidth(); this.modelTextEditor.on("renderLine", function(cm, line, elt) { let off = CodeMirror.countColumn(line.text, null, cm.getOption("tabSize")) * charWidthModelText; elt.style.textIndent = "-" + off + "px"; elt.style.paddingLeft = (basePadding + off) + "px"; }); } ////////////////////////////////////// // LOCAL STORAGE BACKUPS // ////////////////////////////////////// const maxLocalBackupInterval = localStorage.__speedUpLocalBackupStuffForTesting ? 1000*5 : 1000*60*5; // CAUTION: if you change this, change `doLocalBackupDebounce6MinTimeout` to be larger than it const oldLocalBackupCleaningInterval = localStorage.__speedUpLocalBackupStuffForTesting ? 1000*60*2 : 1000*60*20; const maxLocalBackupAge = localStorage.__speedUpLocalBackupStuffForTesting ? 1000*60*10 : 1000*60*60*24*30; const maxLocalBackupsPerGenerator = localStorage.__speedUpLocalBackupStuffForTesting ? 10 : 30; if(localStorage.__speedUpLocalBackupStuffForTesting) { console.warn(`WARNING: local backups sped up due to truthy localStorage.__speedUpLocalBackupStuffForTesting value\n`.repeat(20)); } let lastLocalBackupTime = Date.now(); const doLocalGeneratorBackupIfNeeded = async (modelText, outputTemplate) => { if(!app.userOwnsThisGenerator) return; // they don't own this gen (i.e. are just playing around in editor for now) if(Date.now()-window.generatorLastSaveTime < maxLocalBackupInterval) return; // hasn't been a few mins since last save if(Date.now()-lastLocalBackupTime < maxLocalBackupInterval) return; // at most once every few mins if(window.lastModelTextSaved === modelText && window.lastOutputTemplateSaved === outputTemplate) return; // since they may have made an edit, but then undone it lastLocalBackupTime = Date.now(); if(!kv) await window.initKv(); let backupsArray = await kv.localBackups.get(app.data.generator.name) || []; backupsArray.unshift({modelText, outputTemplate, time:Date.now()}); // if there are more than `maxLocalBackupsPerGenerator` backups, delete every second one in the second half of the array: if(backupsArray.length > maxLocalBackupsPerGenerator) { backupsArray = backupsArray.filter((backup, i) => i < maxLocalBackupsPerGenerator/2 ? true : i%2===0); } await kv.localBackups.set(app.data.generator.name, backupsArray); console.debug("Saved local backup. NEW backupsArray:", backupsArray); }; setTimeout(async () => { if(app.userOwnsThisGenerator) { let persistent = await navigator.storage.persist(); if(!persistent) console.warn("Browser denied persistent local storage."); } }, 1000*60*20); setInterval(async () => { if(!kv) return; let entries = await kv.localBackups.entries(); // for *every* backed-up generator (not just the one they're currently viewing/editing) stored in this user's browser: for(let [generatorName, backupsArray] of entries) { let originalLength = backupsArray.length; // delete the backup array entries older than several weeks: backupsArray = backupsArray.filter(backup => Date.now()-backup.time < maxLocalBackupAge); if(originalLength === backupsArray.length) continue; // save changes: if(backupsArray.length === 0) { await kv.localBackups.delete(generatorName); await kv.localBackupsLastWarnTimes.delete(generatorName); } else { console.debug(`Cleared ${originalLength-backupsArray.length} old backups. NEW backupsArray:`, backupsArray); await kv.localBackups.set(generatorName, backupsArray); } } }, oldLocalBackupCleaningInterval); // if the page loads, and user owns this generator, and the last backup time is more than a few mins forward in time from last save, then warn them to click header 'backups' button setTimeout(async () => { let startTime = Date.now(); while(app.userOwnsThisGenerator === undefined) await new Promise(r => setTimeout(r, 1000*10)); if(Date.now()-startTime > 1000*60) { console.warn(`Took a ${Date.now()-startTime}ms (i.e. much longer than expected) to determine if this user owns this generator?`); return; } if(!app.userOwnsThisGenerator) return; if(!kv) await window.initKv(); let localBackupsArr = await kv.localBackups.get(app.data.generator.name); let lastWarnTimeForThisGen = await kv.localBackupsLastWarnTimes.get(app.data.generator.name) || 0; if(localBackupsArr) { for(let data of localBackupsArr) { if(data.time-window.generatorLastSaveTime > 1000*60*3 && data.time > lastWarnTimeForThisGen) { try { app.goToEditMode(); document.querySelector(`#menuBarEl [data-ref="revisionsButton"]`).style.backgroundColor = "#7d5100"; } catch(e) { console.error(e); } await kv.localBackupsLastWarnTimes.set(app.data.generator.name, Date.now()); alert(`Warning: There's a backup of this generator stored in your browser memory which is newer than the version of this generator you're currently viewing. This could have been caused by a problem where the server didn't properly save your generator, or if your browser previously crashed while you were editing, or it could just be that you made a little edit but then decided not to save it. If you lost some work, click the 'backups' button in the header to download the code for a backed-up version.`); return; } } } }, 1000*3); let editorContentAlreadySavedDebounceTimeout; function checkIfEditorContentAlreadySavedWithDebounce(modelText, outputTemplate) { clearTimeout(editorContentAlreadySavedDebounceTimeout); editorContentAlreadySavedDebounceTimeout = setTimeout(() => { if(window.lastModelTextSaved === modelText && window.lastOutputTemplateSaved === outputTemplate) { if(app.userOwnsThisGenerator) { window.setSaveState("saved"); } } }, 300); } let doLocalBackupDebounce6MinTimeout; window.lastEditorsChangeTime = Date.now(); this.outputTemplateEditor.on("changes", () => { window.lastEditorsChangeTime = Date.now(); window.editsHaveBeenMadeSincePageLoad = true; let modelText = this.modelTextEditor.getValue(); let outputTemplate = this.outputTemplateEditor.getValue(); app.generatorEditedCallback({modelText, outputTemplate}); if(this.outputTemplateEditor.isClean() && this.modelTextEditor.isClean()) { // in case they undo and go back to a saved state if(app.userOwnsThisGenerator) { window.setSaveState("saved"); // must come after generatorEditedCallback, above, since that sets the save state to "unsaved" } } else { checkIfEditorContentAlreadySavedWithDebounce(modelText, outputTemplate); } try { doLocalGeneratorBackupIfNeeded(modelText, outputTemplate); clearTimeout(doLocalBackupDebounce6MinTimeout); doLocalBackupDebounce6MinTimeout = setTimeout(() => { // without this, we can still lose up to 5 minutes of work, which is annoying doLocalGeneratorBackupIfNeeded(modelText, outputTemplate); }, 1000*60*6); } catch(e) { console.error(e); } }); this.modelTextEditor.on("changes", () => { window.lastEditorsChangeTime = Date.now(); window.editsHaveBeenMadeSincePageLoad = true; let modelText = this.modelTextEditor.getValue(); let outputTemplate = this.outputTemplateEditor.getValue(); app.generatorEditedCallback({modelText, outputTemplate}); if(this.outputTemplateEditor.isClean() && this.modelTextEditor.isClean()) { // in case they undo and go back to a saved state if(app.userOwnsThisGenerator) { window.setSaveState("saved"); // must come after generatorEditedCallback, above, since that sets the save state to "unsaved" } } else { checkIfEditorContentAlreadySavedWithDebounce(modelText, outputTemplate); } try { doLocalGeneratorBackupIfNeeded(modelText, outputTemplate); clearTimeout(doLocalBackupDebounce6MinTimeout); doLocalBackupDebounce6MinTimeout = setTimeout(() => { // without this, we can still lose up to 5 minutes of work, which is annoying doLocalGeneratorBackupIfNeeded(modelText, outputTemplate); }, 1000*60*6); } catch(e) { console.error(e); } }); // disable overwrite insert mode: this.outputTemplateEditor.on("keyup", () => { this.outputTemplateEditor.toggleOverwrite(false); }); this.modelTextEditor.on("keyup", () => { this.modelTextEditor.toggleOverwrite(false); }); this.outputTemplateEditor.on("changes", () => { clearTimeout(this.updateOutputDelayTimeout); if(this.autoReload) { this.updateOutputDelayTimeout = setTimeout(() => { this.updateOutput(false, true); }, 800); } }); this.modelTextEditor.on("changes", () => { clearTimeout(this.updateOutputDelayTimeout); if(this.autoReload) { this.updateOutputDelayTimeout = setTimeout(() => { this.updateOutput(false, true); // EDIT: this is commented out because it doesn't make sense - if they haven't reloaded, then the computed meta data is obviously going to be using the old code. // TODO: Make it so that when they send the save request, that's when we send updateMetadataIfNeeded, and we send the updated modelText which is executed with createPerchanceTree or whatever, but is then discarded, rather than replacing the "real" tree and updating everything. // this.outputIframe.contentWindow.postMessage({command:"updateMetadataIfNeeded"}, '*'); // needed because they may not have 'auto' checked, and so they make some changes to their $meta props, then hit save, but the updated $meta stuff isn't sent along with the save request. }, 800); } }); // if(window.DEBUG_FREEZE_MODE) { // this.refs.autoReloadCheckbox.checked = false; // //this.root.querySelector("#output iframe").src = "https://null.perchance.org/debug-freeze"; // } else { // // let url = "https://null.perchance.org/" + app.data.generator.name + "?v=1"; // // let url = `https://${window.generatorPublicId}.perchance.org/` + app.data.generator.name + "?v=2"; // // if(window.needToBustCacheOfIframeOnInitialLoad) url += `?__cacheBuster=${Math.random()}`; // let url = new URL(window.location.href); // url.hostname = `${window.generatorPublicId}.perchance.org`; // if(window.needToBustCacheOfIframeOnInitialLoad) { // url.searchParams.set("__cacheBust", Math.random().toString()+Math.random().toString()); // } // // This is to ensure cache is busted even if they have URL params added to the URL. // // Cloudflare has limits to the number of cache clears, so this is a hacky but full-proof workaround. // // The top-level frame has the `window.thisIsNotAnOldCachedPagePromise` stuff to handle this, but we still // // need to make sure the iframe is also busted. // // We only need this during initial page load - not when reload button / save button is pressed, since in those // // cases the __initWithDataFromParentWindow query param is added to the URL, which means the iframe gets fresh // // data from the parent, regardless of the html that the server sends (i.e. the data that's embedded in the HTML // // is ignored) // url.searchParams.set("__generatorLastEditTime", window.generatorLastSaveTime); // this.root.querySelector("#output iframe").src = url; // } if(window.DEBUG_FREEZE_MODE) { this.refs.autoReloadCheckbox.checked = false; this.root.querySelector("#output iframe").src = "https://null.perchance.org/debug-freeze"; } else if(window.needToBustCacheOfIframeOnInitialLoad) { let url = new URL(window.location.href); url.hostname = `${window.generatorPublicId}.perchance.org`; url.searchParams.set("__cacheBust", Math.random().toString()+Math.random().toString()); this.root.querySelector("#output iframe").src = url; } // whenever the codemirror scrollbar appears or dissapears, update the buttons positioning so they sit beside the scrollbar: const updateCodeEditorButtonsPos = () => { document.querySelector(".code-editor-buttons-ctn").style.right = (document.querySelector(".CodeMirror-vscrollbar").offsetWidth+5)+"px"; }; new MutationObserver(updateCodeEditorButtonsPos).observe(document.querySelector(".CodeMirror-vscrollbar"), {attributes: true}); setTimeout(updateCodeEditorButtonsPos, 2000); // <-- call it once at the start for init // same for html area: const updateHtmlEditorButtonsPos = () => { document.querySelector(".toggle-wrap-button-html").style.right = (document.querySelectorAll(".CodeMirror-vscrollbar")[1].offsetWidth+5)+"px"; }; new MutationObserver(updateHtmlEditorButtonsPos).observe(document.querySelectorAll(".CodeMirror-vscrollbar")[1], {attributes: true}); setTimeout(updateHtmlEditorButtonsPos, 2000); // <-- call it once at the start for init // Initialise Split.js panels let sizesStore = new Store('editor-split-sizes'); let sizes; let defaultSizes = { ab: [60,40], cd: [90,10], ef: [75,25], }; if(sizesStore.data[app.data.generator.name]) { sizes = sizesStore.data[app.data.generator.name]; } else { // default sizes sizes = JSON.parse(JSON.stringify(defaultSizes)); sizesStore.data[app.data.generator.name] = sizes; sizesStore.save(); } function trimSplitSizes(arr) { // for some reason split sizes sometimes add to over 100% and the editor breaks. // this trims it to 100% arr = arr.slice(0); let sum = arr[0] + arr[1]; if(sum <= 100) return arr; else { arr[0] = Math.round(arr[0]); arr[1] = Math.round(arr[1]); while(arr[0] + arr[1] > 100) { if(Math.random() < 0.5) { arr[0]--; } else { arr[1]--; } } return arr; } } let split_ab = Split([this.root.querySelector('#a'), this.root.querySelector('#b')], { gutterSize: 8, sizes: sizes.ab, cursor: 'col-resize', minSize: 30, onDragEnd: function() { let data = sizesStore.data[app.data.generator.name]; if(!data) { data = JSON.parse(JSON.stringify(defaultSizes)); } // they could have changed generator's name data.ab = split_ab.getSizes(); data.ab = trimSplitSizes(data.ab); sizesStore.save(); } }); let split_cd = Split([this.root.querySelector('#c'), this.root.querySelector('#perchanceConsoleEl')], { direction: 'vertical', sizes: sizes.cd, gutterSize: 8, cursor: 'row-resize', minSize: 30, onDragEnd: function() { let data = sizesStore.data[app.data.generator.name]; if(!data) { data = JSON.parse(JSON.stringify(defaultSizes)); } // they could have changed generator's name data.cd = split_cd.getSizes(); data.cd = trimSplitSizes(data.cd); sizesStore.save(); } }); let split_ef = Split([this.root.querySelector('#e'), this.root.querySelector('#f')], { direction: 'vertical', sizes: sizes.ef, gutterSize: 8, cursor: 'row-resize', minSize: 30, onDragEnd: function() { let data = sizesStore.data[app.data.generator.name]; if(!data) { data = JSON.parse(JSON.stringify(defaultSizes)); } // they could have changed generator's name data.ef = split_ef.getSizes(); data.ef = trimSplitSizes(data.ef); sizesStore.save(); } }); // need this because otherwise for some reason there's some weird `overscroll-behaviour` stuff near the edges of the screen (even with touch-action:none overlay workaround). if(window.location.hash !== "#edit") this.root.querySelectorAll("#main .gutter").forEach(el => el.style.display="none"); setTimeout(() => { this.modelTextEditor.refresh(); this.outputTemplateEditor.refresh(); }, 10); ////////////////////// // CONSOLE // ////////////////////// function escapeHtml(html) { return html.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } let consoleOutputStyle = ""; let consoleCommandStore = new Store("console-commands"); if(!consoleCommandStore.data.generators) { consoleCommandStore.data.generators = {}; } let consoleOutputEl = document.querySelector("#console-output"); let consoleInputEl = document.querySelector("#console-input"); consoleOutputEl.addEventListener("click", function() { consoleInputEl.focus(); }); consoleOutputEl.srcdoc = consoleOutputStyle + `e.g. try typing I rolled a \{1-6\}! or [yourListName] (including the brackets) above, and then press enter.`; let previousCommands = consoleCommandStore.data.generators[app.data.generator.name] || [""]; let currCommandIndex = 0; // NOT previousCommands array index!! 0 represents most recent element, 1 is second most recent, etc. consoleInputEl.addEventListener("keydown", async (e) => { // e.preventDefault(); let command = consoleInputEl.value; if(e.which === 13) { // enter let optEl = document.querySelector("#consoleInterpreterOption"); let interpreter = optEl.options[optEl.selectedIndex].value; if(interpreter === "plaintext") { consoleOutputEl.srcdoc = consoleOutputStyle.replace("font-family:monospace;", "font-family:monospace; white-space:pre-wrap;")+(escapeHtml(await this.evaluateText(command))); } else if(interpreter === "html") { consoleOutputEl.srcdoc = consoleOutputStyle+(await this.evaluateText(command)); } // this.root.style.background = "#f1f1f1"; // setTimeout(() => { this.root.style.background = "white"; },100) let prevCommand = previousCommands[previousCommands.length-1]; if(prevCommand !== command) { previousCommands.pop(); previousCommands.push(command); previousCommands.push(""); // put the empty one back on the end } if(previousCommands.length > 1000) { previousCommands.shift(); } currCommandIndex = 1; // <-- set index to that of the command that was just submitted consoleCommandStore.data.generators[app.data.generator.name] = previousCommands; // (not necessary because it keeps reference??) consoleCommandStore.save(); return; } if(e.which === 38) { // up arrow currCommandIndex++; // move towards start of array (counter-intuitive) currCommandIndex = Math.min(previousCommands.length-1, currCommandIndex); // must stay below or at length of command stack consoleInputEl.value = previousCommands[previousCommands.length-1-currCommandIndex]; setTimeout(function() { consoleInputEl.selectionStart = consoleInputEl.selectionEnd = 100000; }, 1); return; } if(e.which === 40) { // down arrow currCommandIndex--; // move towards end off array (counter-intuitive) currCommandIndex = Math.max(0, currCommandIndex); // must stay above or at zero consoleInputEl.value = previousCommands[previousCommands.length-1-currCommandIndex]; setTimeout(function() { consoleInputEl.selectionStart = consoleInputEl.selectionEnd = 100000; }, 1); return; } }); }, };