JavaScript must be enabled to play.
Browser lacks capabilities required to play.
Upgrade or switch to another browser.
Loading…
Object.defineProperty(String.prototype, 'splitMatch', { value(reg) { const matchIndices = [...this.matchAll(reg)].map(m => { return { start: m.index, end: m.index + m[0].length } }), splitMatch = { match: {}, rest: {} }; let last = 0, i = 0; matchIndices.forEach(m => { splitMatch.rest[i++] = this.substring(last, m.start); splitMatch.match[i++] = this.substring(m.start, m.end); last = m.end; }); if (last < this.length) splitMatch.rest[i++] = this.substring(last, this.length); splitMatch.length = i; return splitMatch; } }); window.highlighter = { esc: txt => txt.replaceAll('>', '>').replaceAll('<', '<'), getProfileEntries() { const prf = ['sc', 'css', 'html'], sub = ['any', 'outer', 'inner'], list = [...Object.entries(this.profiles.js)]; prf.forEach(p => { sub.forEach(s => { list.push(...Object.entries(this.profiles[p][s] ?? {})); }) }); return list; }, toElem(txt, pName) { const $out = $(`<code class='macro-code' style='white-space: pre-wrap'>`).append(this.toFrag(txt, pName)); //add right click handler on element //display information pop-up $out.on('contextmenu', e => { //e.preventDefault(); //console.log(e.target); }); return $out; }, toFrag(txt, pName) { const $frag = $(new DocumentFragment()).append(this.processString(txt, pName)); //clear unwanted matches in comments and strings + number part of hexadecimals $frag.find('.comment, .jsComment, .jsString, .htmlString, .cssHexa, .scVar').each((i, e) => e.innerHTML = this.esc(e.innerText)); //fix template literal inserts, clear classes and rerun inserts if needed $frag.find('.jsTemplit').each((_, e) => e.innerHTML = this.esc(e.innerText).replace(/(?<=\${).*?(?=})/gs, m => `<span class=jsTemplitInsert>${this.processString(m, 'js')}</span>`)); return $frag; }, processString(txt, pName) { // do the symbol wrapping txt = this.handler(txt, pName ?? 'sc'); // escape </> txt = this.esc(txt); // process annotations txt = txt.replace(/~~(.*?)~~(.*?)~~/gs, (_, txt, comment) => { // scrub symbols wrappers from the comment text, escape double quotes comment = comment.replace(/⁅.+⦙(.+)⁆/gus, (m, t) => t).replace(/(?<!\\)"/g, '\"'); return `<span class='annotation' tabindex='0' data-annot="${comment}">${txt}</span>`; }); // process symbols into valid html txt = txt.replaceAll('⁅', `<span class=`).replaceAll('⁆', '</span>').replaceAll('⦙', '>'); //sanitize elements then retrieve color spans return txt; }, handler(txt, pName) { const profile = typeof pName === 'string' ? clone(this.profiles[pName]) : pName; //parse block comments from the very start txt = txt.replace(/\/\*.*?\*\//gs, m => `⁅comment⦙${m}⁆`); //has two-parts parsing if (profile.getInner) { const { inner, outer } = profile, split = txt.splitMatch(profile.getInner); //some elements need to be highlighted in both situations if (profile.any) { Object.assign(inner, profile.any); Object.assign(outer, profile.any); } //sc macros get inner js processing if (pName === 'sc') Object.assign(inner, this.profiles.js); //parse inner for (const k in split.match) split.match[k] = this.handler(split.match[k], inner); //parse outer for (const k in split.rest) { split.rest[k] = this.handler(split.rest[k], outer); if (pName === 'sc') split.rest[k] = this.handler(split.rest[k], this.profiles.html); } //re-assemble the text let i = -1, output = ''; while (i++ < split.length) output += split.rest[i] ?? split.match[i] ?? ''; if (pName === 'sc') { //parse scripts as js, styles as css output = output.replace(/(?<=<<script>>).+?(?=<<\/script>>)/gs, m => this.handler(m, this.profiles.js)); output = output.replace(/(?<=<style>).+?(?=<\/style>)/gs, m => this.handler(m, this.profiles.css)); } return output; } for (const name in profile) { const prof = profile[name]; if (prof instanceof RegExp) { txt = txt.replace(prof, m => `⁅${name}⦙${m}⁆`); } else if (Array.isArray(prof)) { txt = txt.replace(this.profiles.keyword, m => prof.includes(m) ? `⁅${name}⦙${m}⁆` : m); } else { //special object const matcher = prof.matcher ?? this.profiles.keyword; txt = txt.replace(matcher, m => `⁅${prof.class ?? name}⦙${m}⁆`); } } return txt; }, profiles: { keyword: /(?<!\w)\w+(?!\w)/g, sc: { getInner: /(?<=<<(?:\w|-|=)+\s).*?(?=>>)/gs, any: { scVar: { matcher: /(?:\$|_)(?:\.|\w|\[.*\])+/g, style: 'color: #00e3e3;' }, scBracket: { matcher: /\[(img|)\[.+?]]/g, style: 'color: aqua;' } }, inner: { scOperators: ['gte', 'lte', 'gt', 'lt', 'to', 'not', 'def', 'ndef', 'is', 'isnot', 'eq', 'neq', 'and', 'or', 'range'] }, outer: { scMacro: /<<\/*(?:\w|-|=)+(?:>>)*|>>/g, scStyle: /@@.+?@@/gu, scTemplate: /(?<!\w)\?\w+/g, htmlElem: /(?<!<)<\/*\w+.*?>/gs //ideally, html element should only be in html.... } }, html: { getInner: /(?<=(?<!<)<\w+\s).*?(?=>)/gs, inner: { scAttrDir: { matcher: /(?:@|sc-eval:)(?=\w)/, desc: 'Attribute directive', ref: 'https://www.motoslave.net/sugarcube/2/docs/#markup-html-attribute-directive', style: '' }, htmlAttr: /[a-zA-Z-0-9]+(?=\s*=)/gs, htmlDigit: /-*\d*\.*\d+/g, htmlString: /('|").*?(?:\1)/g }, outer: { htmlComment: /<!--.+?-->/g, } }, css: { getInner: /{.*?}/gs, any: { cssAnim: ['to', 'from'] }, inner: { cssProp: /(?:\w|-)+(?=\s*:)/g, cssVal: /(?<=:).*?(?=;|})/g, cssString: /('|").*?(?:\1)/g, cssHexa: /#[0-9a-f]+/g, cssUnit: /(?<=\d)(?:r*em|ch|px|s|%|v(?:h|w|min|max)|r*lh|fr)/g, cssDigit: /(?<!\w)(?:-*\d*\.*\d+)/g, cssFunction: /\w+\(.*?\)/g }, outer: { cssType: /(?<=\s|^)\w+/g, cssAtRule: /@(?:\w|-)+/g, cssId: /#(?:\w|-)+/g, cssClass: /\.(?!\d)(?:\w|-)+/g, cssPseudoClass: /(?<!:):(?:\w|-)+/g, cssPseudoElem: /::(?:\w|-)+/g, cssAttr: /\[.*?\]/g } }, js: { //SC namespaces jsNamespace: ['window', 'State', 'UI', 'Dialog', 'Setting', 'Engine', 'Save', 'Story', 'Template', 'Macro', 'Util', 'setup', 'SugarCube', 'Wikifier', 'Parser', 'JSON'], jsDigit: /(?<!\w)(?:-*\d*\.*\d+)(?!\w)/g, jsString: /('|").*?(?:\1)/g, jsReserved: ['let', 'const', 'var', 'return', 'throw', 'function', 'yield', 'this', 'arguments', 'delete', 'for', 'in', 'of', 'while', 'do', 'continue', 'switch', 'case', 'break', 'if', 'else', 'typeof', 'instanceof', 'new', 'class', 'extends', 'static', 'get', 'set', 'try', 'catch', 'async', 'await', 'then'], jsFunction: /\w+(?=\()|=>/g, jsType: ['Array', 'Object', 'Boolean', 'true', 'false', 'String', 'null', 'undefined', 'Error', 'RegExp', 'Date', 'Map', 'Set', 'Number', 'NaN', 'Infinity'], jsTemplit: /`.*?`/gs, jsComment: /\/\/.+/g, jsAssign: /(?<!=)(?:\+|-|\/|\*|\||\?|%|&)*=(?!=|>)/g, jsMath: /(?<!=)(?:\+|-|\/|\*|%|<|>)+(?!=)/g, jsEqual: /(?:>|<|!)={1,2}|={2,3}/g, jsComparator: /(?:\||&|\?){2}(?!=)|!/g, jqSigil: /(?:\$|jQuery)(?=\(|\.)/g, jsRegex: /\/.+?\/[gmiyuvsd]+/g //needs flags to be recognized as regex, not great } } }; highlighter.customize = { classes: { sc: { scVar: 'color: #00e3e3;', scBracket: 'color: aqua;', scOperators: 'color: #bd0000;', scMacro: 'color: #bd0000;', scStyle: 'color: #ff8f00;', scTemplate: 'color: #ff3dbc;', scAttrDir: '', htmlElem: 'color: #ff3dbc;', htmlAttr: 'color: white;', htmlDigit: '', htmlString: 'color: #ff5334;', htmlComment: 'color: gray;', comment: 'color: gray;' }, css: { cssProp: '', cssVal: 'color: #ff5334;', cssStrig: 'color: #ff5334;', cssHexa: 'color: #97ff19;', cssUnit: 'color: #97ff19;', cssDigit: 'color: #97ff19;', cssFunction: 'color: #00e3e3;', cssType: 'color: #9fff9f;', cssAtRule: 'color: #ff8f00;', cssAnim: 'color: #ff8f00;', cssId: '', cssClass: 'color: #63ffef;', cssPseudoClass: 'color: steelblue;', cssPseudoElem: 'color: steelblue;', cssAttr: 'color: #ffafff;' }, js: { jsNamespace: 'color: yellowgreen;', jsDigit: 'color: #97ff19;', jsString: 'color: #ff5334;', jsReserved: 'color: #bd0000;', jsFunction: 'color: #00e3e3;', jsType: '', jsTemplit: 'color: #ff8f00;', jsTemplitInsert: 'color: white;', jsComment: 'color: gray;', jsAssign: 'color: #97ff19;', jsMath: 'color: #97ff19;', jsEqual: 'color: #97ff19;', jsComparator: 'color: #97ff19;', jqSigil: '', jsRegex: 'color: #ff8f00;' } }, styleSheet: $('<style id="highlightStyles">').appendTo('head'), buildStyles(classes) { let rules = ''; for (const k in classes) { for (const className in classes[k]) rules += `.${className} {${classes[k][className]}}`; } this.styleSheet[0].innerText = rules; }, init(withMenu) { this.buildStyles(this.classes); if (withMenu) this.buildMenu(); }, buildMenu() { let classes = recall('highlighterSettings') ?? clone(this.classes); const $menu = $(` <div id='editorMenu' class='stowed'> <div id='topBar'></div> <div id='inputContainer'></div> <span> <button id='applyButton'>Apply changes</button> <button id='resetButton'>Reset to default</button> </span> <span class='side'> <button id='stowButton' title='Stow/unstow'>⇔</button> <button id='loadButton'>Load pattern</button> <button id='getButton'>Get pattern</button> </span> </div> `); Object.entries(classes).forEach(([name, v]) => { //the select button in the topbar const selectBut = $('<button>').text(name).on('click', _ => { $('.active').removeClass('active'); $(`#${name}Panel`).addClass('active'); }); $menu.find('#topBar').append(selectBut); const panel = $(`<div id='${name}Panel' class='inputPanel ${name === 'sc' ? 'active' : ''}'>`); for (const k in v) { const lab = $('<label>').text(k); $(`<input value='${v[k]}'>`).on('change', function () { classes[name][k] = this.value; }).appendTo(lab); panel.append(lab); } $menu.find('#inputContainer').append(panel); }); //handler on apply button $menu.find('#applyButton').on('click', _ => { this.buildStyles(classes); //rebuild style elem memorize('highlighterSettings', classes); //commit to memory }); //handler on reset button //add confirm dialog $menu.find('#resetButton').on('click', _ => { classes = clone(clone(this.classes)); this.buildStyles(classes); //rebuild style elem forget('highlighterSettings'); //commit to memory }); //stow $menu.find('#stowButton').on('click', _ => $menu.toggleClass('stowed')); //output current palette to a dialog, improve $menu.find('#getButton').on('click', _ => { Dialog.setup(); Dialog.append(JSON.stringify(classes)).open(); }); //load a stringified palette object and set it as appearance $menu.find('#loadButton').on('click', _ => { Dialog.setup(); Dialog.append(`<textarea id='paletteInput'>`); const applyButton = $('<button>').text('Load palette'); applyButton.on('click', e => { const obj = JSON.parse($('#paletteInput').val()); //the error should appear in dialog instead if (typeof obj !== 'object') throw new Error(`Improper palette object.`); //do better object validation! classes = obj; this.buildStyles(classes); memorize('highlighterSettings', classes); Dialog.close(); }); Dialog.append(applyButton).open(); }); $('body').append($menu); } }; highlighter.customize.init(true); //simple display elems Macro.add(['js', 'css', 'sc', 'code'], { tags: null, handler() { const mode = this.name === 'code' ? (this.args[0] ?? 'sc') : this.name, $wrp = $(`<div class='macro-code-wrapper ${mode}'>`), $code = $(`<code class='macro-code ${mode}'>`).appendTo($wrp); const config = { clipboard: this.args.includesAny('clipboard', 'copy', 'cb'), editable: this.args.includesAny('editable', 'edit'), output: this.args.includes('output'), nobr : this.args.includes('nobr'), text : this.payload[0].contents.trim() }; // the clipboard button if (config.clipboard) { $(`<button class='copyButton'>`) .text('Copy to clipboard.') .ariaClick({ role: 'button', title: 'Copy to clipboard.', label: 'Copy to clipboard.' }, function () { navigator.clipboard.writeText($code.text()); this.innerText = 'Copied!'; setTimeout(() => {this.innerText = 'Copy to clipboard.'}, 850); }) .appendTo($wrp); } // a wikified output if (config.output) { $wrp.addClass('withOutput'); let txt = config.text; if (config.nobr) txt = txt.replace(/^\n+|\n+$/g, '').replace(/\n+/g, ' '); $(`<div class='macro-code-output'>`).wiki(txt).appendTo($wrp); } // editable => needs the editor to work! // add the code to code! $code.append(highlighter.toFrag(config.text, mode)); this.output.appendChild($wrp[0]); } }); //inline code snippets Macro.add(['snip-sc', 'snip-js', 'snip-css'], { handler() { // decide on mode let mode = this.name.split('-')[1]; const $wrp = $('<code>').addClass('snippet ' + mode); $wrp.append(highlighter.toFrag(this.args[0], mode)); this.output.appendChild($wrp[0]); } }); //editor part! window.codeEditor = class editor { constructor(txt, mode) { this.content = txt || ''; this.subMode = this.mode = mode ?? 'sc'; this.beingTyped = ''; this.selection = null; this.focusNode = null; this.suggestion = { possible: [], dialog: $(`<div class='autocomplete'>`) }; this.elem = $(`<code class='${this.mode} code-editor' style='white-space: pre-wrap' contenteditable='true' spellcheck='false'>`).append(highlighter.toFrag(this.content, this.mode)); this.wrapper = $(`<div class='editorWrapper'>`).append(this.elem, this.suggestion.dialog); const toClose = { "'": "'", '"': '"', '`': '`', '{': '}', '(': ')', '[': ']', }; this.elem.on('keydown', e => { const isLetter = e.key.length === 1 && /\w/.test(e.key); //new selection this.selection = getSelection(); this.getSubMode(); log(this.selection); //tab needs to run before to take into account last time's suggestions! if (e.key === 'Tab') { const sug = this.suggestion.possible; this.insert(sug.length ? sug[0].slice(this.beingTyped.length) : '\t'); return false; } //use selection to find the word being typed! const { focusNode, focusOffset } = this.selection; const prevLetter = focusNode.textContent[focusOffset - 1], nextLetter = focusNode.textContent[focusOffset]; log(prevLetter, nextLetter); if (isLetter) {//a normal character this.beingTyped += e.key; } else if (e.key === 'Backspace') {//backspace, trim typed this.beingTyped = this.beingTyped.slice(0, -1); } else { this.beingTyped = ''; } this.getSuggestion(); if (e.key === 'Enter' && e.ctrlKey) { this.content = this.elem[0].innerText; return this.refresh(); } if (toClose[e.key]) { this.insert(e.key + toClose[e.key], -1); return false; } if (this.subMode === 'sc' && e.key === '<') { this.insert('<>', -1); return false; } }); this.elem.on('click', e => { this.selection = getSelection(); this.getSubMode(); }); } refresh() { //get absolute caret position const pos = this.absCaretPos(); this.elem.empty().append(highlighter.toFrag(this.content, this.mode)); //set caret back to the same place this.setCaretAt(pos); } //count nodes, get and set absolute caret position getFlatNodes() { let ch = [...this.elem[0].childNodes]; while (ch.some(c => c.nodeType !== 3)) ch = ch.map(n => n.nodeType === 3 ? n : [...n.childNodes]).flat(); return ch; } absCaretPos() { const { focusNode, focusOffset } = this.selection; let count = 0, pos; this.getFlatNodes().forEach(n => { if (n === focusNode) pos = count + focusOffset; count += n.length; }); return pos; } setCaretAt(pos) { let count = 0, node, nodes = this.getFlatNodes(), i = 0; while (count < pos) { node = nodes[i++]; count += node.length; } if (!node) return this.elem.focus(); getSelection().setPosition(node, Math.max(0, node.length - (count - pos))); } getSubMode() { if (this.selection.focusNode === this.elem[0]) return this.subMode = this.mode; const parent = this.selection.focusNode.parentNode; this.subMode = parent.classList[0].substring(0, 2); } insert(char, offset = 0) { const node = this.selection.focusNode, range = this.selection.getRangeAt(0); if (node.nodeType === 3) { const pos = this.selection.focusOffset; if (!node.nodeValue) { node.nodeValue = char; } else { node.nodeValue = node.nodeValue.substring(0, pos) + char + node.nodeValue.substring(pos); } range.setStart(node, pos + char.length + offset); } else {//No text node, create one const txtNode = document.createTextNode(char); node.appendChild(txtNode); range.setStart(txtNode, char.length + offset); } } getSuggestion() { this.suggestion.dialog.empty(); if (!this.beingTyped) { this.suggestion.dialog.hide(); return this.suggestion.possible = []; }; //handle to visual part! this.suggestion.possible = codeEditor.autocomplete[this.subMode].filter(e => e.startsWith(this.beingTyped)); this.suggestion.dialog.show().append(this.suggestion.possible.join()); } static autocomplete = { //gotta figure it out... sc: ['silently', 'set', 'link', 'textbox', 'capture', 'script', 'stop', 'range', 'for', 'textarea', 'unset', 'addclass', 'run', 'include', 'nobr', 'print', 'type', 'if', 'elseif', 'else', 'break', 'continue', 'switch', 'case', 'button', 'checkbox'], js: [], css: [] } }; // <<editor mode>> stuff <</editor>> Macro.add('editor', { tags: null, handler() { const editor = new codeEditor(this.payload[0].contents, this.args[0]); editor.wrapper.appendTo(this.output); } });
<header data-passage='topbar'></header> <main id='passages'></main>
<<but [[CSS]] disabled 'passage() === "CSS"'>><</but>> <<but [[JS]] disabled 'passage() === "JS"'>><</but>> <<but [[Sugarcube]] disabled 'passage() === "Sugarcube"'>><</but>> <<but [[Edit mode]] disabled 'passage() === "Edit mode"'>><</but>> <<but [[Snippets]] disabled 'passage() === "Snippets"'>><</but>> <<but [[editorTest]] goto 'editorTest' disabled 'passage() === "editorTest"'>><</but>>
<<css>> /* <<drag>> element */ .macro-drag { min-height: 4em; min-width: 4em; border: .15em solid #333; cursor: grab; user-select: none; background-color: #111; padding: .25em; position: relative; max-width: fit-content; } .macro-drag:active {cursor:grabbing} .macro-drag.touchActive, .macro-drag:hover { scale: 110%; z-index: 10; box-shadow: 0 0 .5em .5em black; transition: .2s; border-color: revert; } /* Quantity number */ .macro-drag[data-quantity]::after { content: attr(data-quantity); position: absolute; bottom: 0; right: .4em; } /* Drop */ .macro-drop { min-height: 5em; min-width: 5em; display: grid; grid-auto-flow: column; place-items: center; border: .15em solid #444; width: fit-content; padding: .5em; gap: .5em; margin: .5em; background: repeating-linear-gradient( 45deg, black, black .5em, #444 1em, #444 1em); box-shadow: 0 0 .25em .5em black inset; position: relative; } .macro-drop.destroy { transition:.2s; border-color: firebrick; box-shadow: 0 0 .5em .25em red; } /* Overlays */ .macro-drop::after, .macro-drop::before { position: absolute; z-index: 2; display: grid; place-items: center; left: 0; top: 0; height: 100%; width: 100%; font-size: 1.4em; pointer-events: none; color: #666; text-shadow: 0 0 .2em black, 0 0 .3em black, 0 0 .4em black, 0 0 .5em black; opacity: 0; text-transform: capitalize; transition: .2s; } /* Slots display */ .macro-drop[data-slots]::before { content: attr(data-slots); opacity: 1; top: unset; height: fit-content; bottom: -25%; z-index : 4; } /* With icons */ .macro-drop.dropPossible::after, .macro-drop.dropForbidden::after {outline: 2px solid; opacity:1} .macro-drop.dropForbidden::after {color: red; content: '⦸';font-size: 2em} .macro-drop.dropPossible::after {color: lime} .macro-drop.dropPossible::after {content: '⭣';color: lime} .macro-drop.dropPossible[data-drop=none] {border-color: #444;} .macro-drop.dropPossible[data-drop=none]::after {content: '⭘';color: #444;} .macro-drop.dropPossible[data-drop=replace]::after {content: '⧄⊠⧅';font-size: 1.25em} .macro-drop.dropPossible[data-drop=replaceAll]::after {content: '⊠⊠⊠';font-size: 1.25em} .macro-drop.dropPossible[data-drop=append]::after {content: '⭢'} .macro-drop.dropPossible[data-drop=prepend]::after {content: '⭠'} .macro-drop.dropPossible[data-drop=swap]::after {content: '⥯'} .macro-drop.dropPossible[data-drop=remove] {border-color: darkorange} .macro-drop.dropPossible[data-drop=remove]::after {content: '♺';color: darkorange} /* With explicit names */ .macro-drop.explicit::after {font-size: .8em !important} .macro-drop.explicit.dropForbidden::after {content: 'Forbidden'} .macro-drop.explicit.dropPossible::after {content: 'Anywhere';} .macro-drop.explicit.dropPossible[data-drop=none]::after {content: 'No effect';color: #444;} .macro-drop.explicit.dropPossible[data-drop=replaceAll]::after {content: 'Replace all'} .macro-drop.explicit.dropPossible[data-drop=replace]::after, .macro-drop.explicit.dropPossible[data-drop=append]::after, .macro-drop.explicit.dropPossible[data-drop=prepend]::after, .macro-drop.explicit.dropPossible[data-drop=swap]::after, .macro-drop.explicit.dropPossible[data-drop=remove]::after {content: attr(data-drop);} <</css>>
<<js>>Wikifier.Parser.add({ name: 'swapMarkup', match: '\\(.*?\\|.*?\\)', handler: ({matchText: m, output: o}) => { let acc = 0, entries = m.substring(1, m.length - 1).split('|').map((e,i) => { let hasOdd = e.match(/^(\d*\.*\d+)(?=:)/), odd = 1, txt = e; if (hasOdd) { odd = Number(hasOdd[0]); txt = txt.slice(hasOdd[0].length+1); } odd = acc += odd; return {odd,txt}; }); return $(o).wiki(entries.find(e => e.odd > randomFloat(acc)).txt); } }); <</js>> <p> From the <a href='https://github.com/MalifaciousGames/Mali-s-Scripts/tree/main/swap-markup'>swap markup</a>. </p> <<js>> /* KeyControl API, ~~format-agnostic~~ here we comment the comment!~~, only requires jQuery */ window.KeyControl = class KeyControl { constructor(id, def) { if (typeof id !== 'string') throw new Error('Keybinding ID must be a string.'); if (this.constructor.active.find(l => l.id === id)) throw new Error(`A key listener with the ${id} ID already exists.`); this.id = id; for (const k in def) { this[k] = def[k]; } //key inputs if (!this.key) throw new Error('No input keys!'); if (typeof this.key === 'string') this.key = this.key.split(' '); if (!Array.isArray(this.key)) throw new Error('Improper key type, must be either a string or an array!'); //special keys if (typeof this.special === 'string') this.special = this.special.split(' '); if (this.special != null && !Array.isArray(this.special)) throw new Error('Improper special key, must be either a string or an array!'); if (this.special?.some(k => !['ctrl', 'alt', 'shift'].includes(k))) throw new Error('Special keys can only be: ctrl, shift or alt.'); this.default = { key: this.key, special: this.special }; //try to fetch key values from memory const fromMem = this.keysFromMemory(); if (fromMem) { for (const k in fromMem) { this[k] = fromMem[k]; } } //condition if (this.condition != null && typeof this.condition !== 'function') throw new Error('Improper condition type, must be a function.'); //callback if (typeof this.callback !== 'function') throw new Error('Improper callback type, must be a function.'); this.active ??= true; this.setDisplay(); this.constructor.active.push(this); } invoke(e) { if (!this.key.find(k => k === e.key || k === e.code)) return; if (this.special && !this.special.every(k => e[k + 'Key'])) return; if (this.condition && !this.condition.call(this, e)) return; this.callback.call(this, e); if (this.once) this.delete(); } setKey(e, spcKey) { this.special = ['ctrl', 'alt', 'shift'].filter(k => e[k + 'Key']); if (spcKey) this.special.pushUnique(spcKey); this.key = [e.key]; this.keysToMemory(); this.setDisplay(); } reset() { this.key = this.default.key; this.special = this.default.special; localStorage.removeItem(this.getMemoryId()); this.setDisplay(); } setDisplay() { let val = ''; if (this.special?.length) val += this.special.join(' + ') + ' + '; val += this.key.join(' / '); return this.displayVal = val; } createInput() { this.input = $(`<input readonly>`).attr({ id: this.id, class: 'keyInput', placeholder: 'Enter key binding' }).val(this.displayVal); this.input.on('keydown', e => { if (e.key === 'Tab') return; e.preventDefault(); e.stopPropagation(); this.setKey(e); this.input.val(this.displayVal); }).on('focus', _ => this.input.val('')) .on('focusout', _ => this.input.val(this.displayVal)); return this.input; } createResetButton() { return $(`<button class='keyReset'>Reset to default</button>`).attr({ 'aria-label': 'Reset to default value', role: 'button' }).on('click', _ => { this.reset(); this.input.val(this.displayVal); }); } createInputContext() { const $wrp = $(`<div class='keyWrapper'>`).append(`<span class='keyName'>${this.name ?? this.id}</span>`); this.createInput().appendTo($wrp); this.createResetButton().appendTo($wrp); return $wrp; } getMemoryId() { const tw = $('tw-storydata')[0]; return tw.getAttribute('name') + ':' + tw.getAttribute('ifid') + ':' + this.id; } keysFromMemory() { const keys = localStorage.getItem(this.getMemoryId()); return keys ? JSON.parse(keys) : null; } keysToMemory() { const kObj = { key: this.key, special: this.special }; localStorage.setItem(this.getMemoryId(), JSON.stringify(kObj)); } delete() { const act = this.constructor.active, i = act.findIndex(k => k === this); act.splice(i, 1); } disable() { return this.active = false } toggle() { return this.active = !this.active } enable() { return this.active = true } static active = []; static coolDown = false; static run(e) { if (this.coolDown) return; this.coolDown = true; setTimeout(() => { this.coolDown = false }, 200); this.active.filter(l => l.active).forEach(l => l.invoke(e)); }; static add(id, def) { return new this(id, def); }; static get(id) { const l = this.active.find(l => l.id === id); if (!l) throw Error(`No listener found for the ${id} id.`); return l; }; static remove(id) { this.get(id).delete(); }; static createInputPanel() { const $grd = $(`<div class='keyInputPanel'>`); if (this.active.length) { this.active.forEach(kb => $grd.append(kb.createInputContext())); } else { $grd.append(`<span>No active key bindings.</span>`); } return $grd; }; static openInputDialog() { if (!Dialog) throw new Error(`KeyControl.openInputDialog() can only be used with the Sugarcube story format.`); Dialog.setup('Input panel', 'keyInputDialog'); return Dialog.append(this.createInputPanel()).open(); } }; $(document).on('keydown.KeyControlAPI', e => KeyControl.run(e)); $('#style-story').before(`<style id='KeyControlStyling'> .keyInputPanel {display:grid;} .keyWrapper {display: grid;grid-template-columns: 1fr 2fr 1fr;gap: 1em;padding: .5em;} </style>`); /* End of the API */ <</js>> <p> From the <a href='https://github.com/MalifaciousGames/Mali-s-Macros/tree/main/keycontrol-macros'>KeyControl API</a>. </p>
<<sc>><<if visited("Cabin1")>> ~~<<a 'Go to the wooden cabin.' goto 'Forest2'>><</a>>~~You have been to the cabin before.~~ <<elseif $cabinaware>> <<a 'Set out to find the wooden cabin.' goto 'Forest2'>><</a>> <</if>> <p>The church's belltower stands tall above the (silent|overgrown|empty) churchyard.</p> [[Enter the church.|Porch]] [[Explore the cemetery.|Cemetery]] <<if hasVisited("Cemdream")>> ~~<b class="dejavu">Something feels off.</b>~~The player has already died once in dream. ~~ <</if>> <<if $blade and !$backpack.contains("Dagger")>> <<run $backpack.push(setup.Dagger)>> <</if>> <</sc>> <p>From ChurchCrawler (unreleased).</p> <<sc>><p>The space is small, just wide enough for the length of the sofa.</p> <p>A rectangular velvet-covered box lies on the tall octogonal table.</p> <<reveal 'Open it.'>> <<say 'Not bad...'>> <<set $player.consummables.chocolates = 5>> <p>Chocolates. The box holds five heart-shaped chocolates.</p> <<step 'Eat one.'>><<run Item.chocolates.use()>> <p>Tasty.</p> <<step 'Another one.'>><<run Item.chocolates.use()>> <p>Invigorating.</p> <<step 'One more.'>><<run Item.chocolates.use()>> <p>Might be a bit much.</p> <p>You pocket the rest, ready to [[leave|hotelEscape3]].</p> <</reveal>> <</sc>> <p>From KISSMATIC (unreleased)</p> <<sc>>/* script macro gets standard JS highlighting */ <<script>> const arr = [1,2,3]; arr.forEach(n => n*= 5); <</script>> /* style element gets standard CSS highlighting */ <style> button { border : .15rem solid green; background-color : transparent; } button::before, button::after { content : '!'; color : grey; } </style> <</sc>> <p>Made up!</p>
<p>Ctrl + Enter to update highlighting.</p> <<editor>><</editor>>
<<sc output copy>> <<link 'ah'>> stuff <</link>> <div/> <</sc>> <<sc copy>> Time until the bomb explodes: <<showmeter 'timer' 1>> @@#boom;@@ <<run Meter.get('timer').on(':meter-animation-complete', function () {$('#boom').append('BOOOOOOOOM!!!!');})>> <</sc>>
<<sc>> <<snip-sc "A short snippet of <sugarcube> ?code.">> <br> <<snip-js "A short snippet of JS code. while(true) doThing(); ">> <br> <<snip-css "just .someCSS { pretty : true}">> <</sc>> <<snip-sc "A short snippet of <sugarcube> ?code.">> <br> <<snip-js "A short snippet of JS code. while(true) doThing(); ">> <br> <<snip-css "just .someCSS { pretty : true}">>