Spaces:
Runtime error
Runtime error
| /** | |
| * Copyright (C) 2021 Thomas Weber | |
| * | |
| * This program is free software: you can redistribute it and/or modify | |
| * it under the terms of the GNU General Public License version 3 as | |
| * published by the Free Software Foundation. | |
| * | |
| * This program is distributed in the hope that it will be useful, | |
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| * GNU General Public License for more details. | |
| * | |
| * You should have received a copy of the GNU General Public License | |
| * along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| */ | |
| import IntlMessageFormat from 'intl-messageformat'; | |
| import SettingsStore from './settings-store-singleton'; | |
| import dataURLToBlob from '../lib/data-uri-to-blob'; | |
| import EventTargetShim from './event-target'; | |
| import AddonHooks from './hooks'; | |
| import addons from './generated/addon-manifests'; | |
| import addonMessages from './addons-l10n/en.json'; | |
| import l10nEntries from './generated/l10n-entries'; | |
| import addonEntries from './generated/addon-entries'; | |
| import {addContextMenu} from './contextmenu'; | |
| import * as modal from './modal'; | |
| import * as textColorHelpers from './libraries/common/cs/text-color.esm.js'; | |
| import './polyfill'; | |
| import * as conditionalStyles from './conditional-style'; | |
| import getPrecedence from './addon-precedence'; | |
| /* eslint-disable no-console */ | |
| const escapeHTML = str => str.replace(/([<>'"&])/g, (_, l) => `&#${l.charCodeAt(0)};`); | |
| const kebabCaseToCamelCase = str => str.replace(/-([a-z])/g, g => g[1].toUpperCase()); | |
| let _scratchClassNames = null; | |
| const getScratchClassNames = () => { | |
| if (_scratchClassNames) { | |
| return _scratchClassNames; | |
| } | |
| const cssRules = Array.from(document.styleSheets) | |
| // Ignore some scratch-paint stylesheets | |
| .filter(styleSheet => ( | |
| !( | |
| styleSheet.ownerNode.textContent.startsWith( | |
| '/* DO NOT EDIT\n@todo This file is copied from GUI and should be pulled out into a shared library.' | |
| ) && | |
| ( | |
| styleSheet.ownerNode.textContent.includes('input_input-form') || | |
| styleSheet.ownerNode.textContent.includes('label_input-group_') | |
| ) | |
| ) | |
| )) | |
| .map(e => { | |
| try { | |
| return [...e.cssRules]; | |
| } catch (_e) { | |
| return []; | |
| } | |
| }) | |
| .flat(); | |
| const classes = cssRules | |
| .map(e => e.selectorText) | |
| .filter(e => e) | |
| .map(e => e.match(/(([\w-]+?)_([\w-]+)_([\w\d-]+))/g)) | |
| .filter(e => e) | |
| .flat(); | |
| _scratchClassNames = [...new Set(classes)]; | |
| const observer = new MutationObserver(mutationList => { | |
| for (const mutation of mutationList) { | |
| for (const node of mutation.addedNodes) { | |
| if (node.tagName === 'STYLE') { | |
| _scratchClassNames = null; | |
| observer.disconnect(); | |
| return; | |
| } | |
| } | |
| } | |
| }); | |
| observer.observe(document.head, { | |
| childList: true | |
| }); | |
| return _scratchClassNames; | |
| }; | |
| let _mutationObserver; | |
| let _mutationObserverCallbacks = []; | |
| const addMutationObserverCallback = newCallback => { | |
| if (!_mutationObserver) { | |
| _mutationObserver = new MutationObserver(() => { | |
| for (const cb of _mutationObserverCallbacks) { | |
| cb(); | |
| } | |
| }); | |
| _mutationObserver.observe(document.documentElement, { | |
| attributes: false, | |
| childList: true, | |
| subtree: true | |
| }); | |
| } | |
| _mutationObserverCallbacks.push(newCallback); | |
| }; | |
| const removeMutationObserverCallback = callback => { | |
| _mutationObserverCallbacks = _mutationObserverCallbacks.filter(i => i !== callback); | |
| }; | |
| class Redux extends EventTargetShim { | |
| constructor () { | |
| super(); | |
| this._isInReducer = false; | |
| this._initialized = false; | |
| this._nextState = null; | |
| } | |
| initialize () { | |
| if (!this._initialized) { | |
| AddonHooks.appStateReducer = (action, prev, next) => { | |
| this._isInReducer = true; | |
| this._nextState = next; | |
| this.dispatchEvent(new CustomEvent('statechanged', { | |
| detail: { | |
| action, | |
| prev, | |
| next | |
| } | |
| })); | |
| this._nextState = null; | |
| this._isInReducer = false; | |
| }; | |
| this._initialized = true; | |
| } | |
| } | |
| dispatch (m) { | |
| if (this._isInReducer) { | |
| queueMicrotask(() => AddonHooks.appStateStore.dispatch(m)); | |
| } else { | |
| AddonHooks.appStateStore.dispatch(m); | |
| } | |
| } | |
| get state () { | |
| if (this._nextState) return this._nextState; | |
| return AddonHooks.appStateStore.getState(); | |
| } | |
| } | |
| const getEditorMode = () => { | |
| // eslint-disable-next-line no-use-before-define | |
| const mode = tabReduxInstance.state.scratchGui.mode; | |
| if (mode.isEmbedded) return 'embed'; | |
| if (mode.isFullScreen) return 'fullscreen'; | |
| if (mode.isPlayerOnly) return 'projectpage'; | |
| return 'editor'; | |
| }; | |
| const tabReduxInstance = new Redux(); | |
| const language = tabReduxInstance.state.locales.locale.split('-')[0]; | |
| const getTranslations = async () => { | |
| if (l10nEntries[language]) { | |
| const localeMessages = await l10nEntries[language](); | |
| Object.assign(addonMessages, localeMessages); | |
| } | |
| }; | |
| const addonMessagesPromise = getTranslations(); | |
| const untilInEditor = () => { | |
| if ( | |
| !tabReduxInstance.state.scratchGui.mode.isPlayerOnly || | |
| tabReduxInstance.state.scratchGui.mode.isEmbedded | |
| ) { | |
| return; | |
| } | |
| return new Promise(resolve => { | |
| const handler = () => { | |
| if (!tabReduxInstance.state.scratchGui.mode.isPlayerOnly) { | |
| resolve(); | |
| tabReduxInstance.removeEventListener('statechanged', handler); | |
| } | |
| }; | |
| tabReduxInstance.initialize(); | |
| tabReduxInstance.addEventListener('statechanged', handler); | |
| }); | |
| }; | |
| const getDisplayNoneWhileDisabledClass = id => `addons-display-none-${id}`; | |
| const parseArguments = code => code | |
| .split(/(?=[^\\]%[nbs])/g) | |
| .map(i => i.trim()) | |
| .filter(i => i.charAt(0) === '%') | |
| .map(i => i.substring(0, 2)); | |
| const fixDisplayName = displayName => displayName.replace(/([^\s])(%[nbs])/g, (_, before, arg) => `${before} ${arg}`); | |
| const compareArrays = (a, b) => JSON.stringify(a) === JSON.stringify(b); | |
| let _firstAddBlockRan = false; | |
| const addonBlockColor = { | |
| color: '#29beb8', | |
| secondaryColor: '#3aa8a4', | |
| tertiaryColor: '#3aa8a4' | |
| }; | |
| const contextMenuCallbacks = []; | |
| const CONTEXT_MENU_ORDER = ['editor-devtools', 'block-switching', 'blocks2image', 'swap-local-global']; | |
| let createdAnyBlockContextMenus = false; | |
| const getInternalKey = element => Object.keys(element).find(key => key.startsWith('__reactInternalInstance$')); | |
| class Tab extends EventTargetShim { | |
| constructor (id) { | |
| super(); | |
| this._id = id; | |
| this._seenElements = new WeakSet(); | |
| // traps is public API | |
| this.traps = { | |
| get vm () { | |
| return tabReduxInstance.state.scratchGui.vm; | |
| }, | |
| getBlockly: () => { | |
| if (AddonHooks.blockly) { | |
| return Promise.resolve(AddonHooks.blockly); | |
| } | |
| return new Promise(resolve => { | |
| AddonHooks.blocklyCallbacks.push(() => resolve(AddonHooks.blockly)); | |
| }); | |
| }, | |
| getPaper: async () => { | |
| const modeSelector = await this.waitForElement("[class*='paint-editor_mode-selector']", { | |
| reduxCondition: state => ( | |
| state.scratchGui.editorTab.activeTabIndex === 1 && !state.scratchGui.mode.isPlayerOnly | |
| ) | |
| }); | |
| const reactInternalKey = Object.keys(modeSelector) | |
| .find(key => key.startsWith('__reactInternalInstance$')); | |
| const internalState = modeSelector[reactInternalKey].child; | |
| // .tool or .blob.tool only exists on the selected tool | |
| let toolState = internalState; | |
| let tool; | |
| while (toolState) { | |
| const toolInstance = toolState.child.stateNode; | |
| if (toolInstance.tool) { | |
| tool = toolInstance.tool; | |
| break; | |
| } | |
| if (toolInstance.blob && toolInstance.blob.tool) { | |
| tool = toolInstance.blob.tool; | |
| break; | |
| } | |
| toolState = toolState.sibling; | |
| } | |
| if (tool) { | |
| const paperScope = tool._scope; | |
| return paperScope; | |
| } | |
| throw new Error('cannot find paper :('); | |
| }, | |
| getInternalKey | |
| }; | |
| } | |
| get redux () { | |
| return tabReduxInstance; | |
| } | |
| waitForElement (selector, {markAsSeen = false, condition, reduxCondition, reduxEvents} = {}) { | |
| let externalEventSatisfied = true; | |
| const evaluateCondition = () => { | |
| if (!externalEventSatisfied) return false; | |
| if (condition && !condition()) return false; | |
| if (reduxCondition && !reduxCondition(tabReduxInstance.state)) return false; | |
| return true; | |
| }; | |
| if (evaluateCondition()) { | |
| const firstQuery = document.querySelectorAll(selector); | |
| for (const element of firstQuery) { | |
| if (this._seenElements.has(element)) continue; | |
| if (markAsSeen) this._seenElements.add(element); | |
| return Promise.resolve(element); | |
| } | |
| } | |
| let reduxListener; | |
| if (reduxEvents) { | |
| externalEventSatisfied = false; | |
| reduxListener = ({detail}) => { | |
| const type = detail.action.type; | |
| // As addons can't run before DOM exists here, ignore fontsLoaded/SET_FONTS_LOADED | |
| // Otherwise, as our font loading is very async, we could activate more often than required. | |
| if (reduxEvents.includes(type) && type !== 'fontsLoaded/SET_FONTS_LOADED') { | |
| externalEventSatisfied = true; | |
| } | |
| }; | |
| this.redux.initialize(); | |
| this.redux.addEventListener('statechanged', reduxListener); | |
| } | |
| return new Promise(resolve => { | |
| const callback = () => { | |
| if (!evaluateCondition()) { | |
| return; | |
| } | |
| const elements = document.querySelectorAll(selector); | |
| for (const element of elements) { | |
| if (this._seenElements.has(element)) continue; | |
| resolve(element); | |
| removeMutationObserverCallback(callback); | |
| if (markAsSeen) this._seenElements.add(element); | |
| if (reduxListener) { | |
| this.redux.removeEventListener('statechanged', reduxListener); | |
| } | |
| break; | |
| } | |
| }; | |
| addMutationObserverCallback(callback); | |
| }); | |
| } | |
| appendToSharedSpace ({space, element, order, scope}) { | |
| const SHARED_SPACES = { | |
| stageHeader: { | |
| element: () => document.querySelector("[class^='stage-header_stage-size-row']"), | |
| from: () => [], | |
| until: () => [ | |
| document.querySelector("[class^='stage-header_stage-size-toggle-group']"), | |
| document.querySelector("[class^='stage-header_stage-size-row']").lastChild | |
| ] | |
| }, | |
| fullscreenStageHeader: { | |
| element: () => document.querySelector("[class^='stage-header_stage-menu-wrapper']"), | |
| from: function () { | |
| let emptyDiv = this.element().querySelector('.addon-spacer'); | |
| if (!emptyDiv) { | |
| emptyDiv = document.createElement('div'); | |
| emptyDiv.style.marginLeft = 'auto'; | |
| emptyDiv.className = 'addon-spacer'; | |
| this.element().insertBefore(emptyDiv, this.element().lastChild); | |
| } | |
| return [emptyDiv]; | |
| }, | |
| until: () => [document.querySelector("[class^='stage-header_stage-menu-wrapper']").lastChild] | |
| }, | |
| afterGreenFlag: { | |
| element: () => document.querySelector("[class^='controls_controls-container']"), | |
| from: () => [], | |
| until: () => [document.querySelector("[class^='stop-all_stop-all']")] | |
| }, | |
| afterStopButton: { | |
| element: () => document.querySelector("[class^='controls_controls-container']"), | |
| from: () => [document.querySelector("[class^='stop-all_stop-all']")], | |
| until: () => [] | |
| }, | |
| afterSoundTab: { | |
| element: () => document.querySelector("[class^='react-tabs_react-tabs__tab-list']"), | |
| from: () => [document.querySelector("[class^='react-tabs_react-tabs__tab-list']").children[2]], | |
| until: () => [document.querySelector('.sa-find-bar')] | |
| }, | |
| assetContextMenuAfterExport: { | |
| element: () => scope, | |
| from: () => Array.prototype.filter.call( | |
| scope.children, | |
| c => c.textContent === this.scratchMessage('gui.spriteSelectorItem.contextMenuExport') | |
| ), | |
| until: () => Array.prototype.filter.call( | |
| scope.children, | |
| c => c.textContent === this.scratchMessage('gui.spriteSelectorItem.contextMenuDelete') | |
| ) | |
| }, | |
| assetContextMenuAfterDelete: { | |
| element: () => scope, | |
| from: () => Array.prototype.filter.call( | |
| scope.children, | |
| c => c.textContent === this.scratchMessage('gui.spriteSelectorItem.contextMenuDelete') | |
| ), | |
| until: () => [] | |
| }, | |
| paintEditorZoomControls: { | |
| element: () => document.querySelector('.sa-paintEditorZoomControls-wrapper') || (() => { | |
| const wrapper = Object.assign(document.createElement('div'), { | |
| className: 'sa-paintEditorZoomControls-wrapper' | |
| }); | |
| wrapper.style.display = 'flex'; | |
| wrapper.style.flexDirection = 'row-reverse'; | |
| wrapper.style.height = 'calc(1.95rem + 2px)'; | |
| const zoomControls = document.querySelector("[class^='paint-editor_zoom-controls']"); | |
| zoomControls.replaceWith(wrapper); | |
| wrapper.appendChild(zoomControls); | |
| return wrapper; | |
| })(), | |
| from: () => [], | |
| until: () => [] | |
| } | |
| }; | |
| const spaceInfo = SHARED_SPACES[space]; | |
| const spaceElement = spaceInfo.element(); | |
| if (!spaceElement) return false; | |
| const from = spaceInfo.from(); | |
| const until = spaceInfo.until(); | |
| element.dataset.saSharedSpaceOrder = order; | |
| let foundFrom = false; | |
| if (from.length === 0) foundFrom = true; | |
| // insertAfter = element whose nextSibling will be the new element | |
| // -1 means append at beginning of space (prepend) | |
| // This will stay null if we need to append at the end of space | |
| let insertAfter = null; | |
| const children = Array.from(spaceElement.children); | |
| for (const indexString of children.keys()) { | |
| const child = children[indexString]; | |
| const i = Number(indexString); | |
| // Find either element from "from" before doing anything | |
| if (!foundFrom) { | |
| if (from.includes(child)) { | |
| foundFrom = true; | |
| // If this is the last child, insertAfter will stay null | |
| // and the element will be appended at the end of space | |
| } | |
| continue; | |
| } | |
| if (until.includes(child)) { | |
| // This is the first SA element appended to this space | |
| // If from = [] then prepend, otherwise append after | |
| // previous child (likely a "from" element) | |
| if (i === 0) insertAfter = -1; | |
| else insertAfter = children[i - 1]; | |
| break; | |
| } | |
| if (child.dataset.addonSharedSpaceOrder) { | |
| if (Number(child.dataset.addonSharedSpaceOrder) > order) { | |
| // We found another SA element with higher order number | |
| // If from = [] and this is the first child, prepend. | |
| // Otherwise, append before this child. | |
| if (i === 0) insertAfter = -1; | |
| else insertAfter = children[i - 1]; | |
| break; | |
| } | |
| } | |
| } | |
| if (!foundFrom) return false; | |
| // It doesn't matter if we didn't find an "until" | |
| if (insertAfter === null) { | |
| // This might happen with until = [] | |
| spaceElement.appendChild(element); | |
| } else if (insertAfter === -1) { | |
| // This might happen with from = [] | |
| spaceElement.prepend(element); | |
| } else { | |
| // Works like insertAfter but using insertBefore API. | |
| // nextSibling cannot be null because insertAfter | |
| // is always set to children[i-1], so it must exist | |
| spaceElement.insertBefore(element, insertAfter.nextSibling); | |
| } | |
| return true; | |
| } | |
| addBlock (procedureCode, {args, displayName, callback}) { | |
| const procCodeArguments = parseArguments(procedureCode); | |
| if (args.length !== procCodeArguments.length) { | |
| throw new Error('Procedure code and argument list do not match'); | |
| } | |
| if (displayName) { | |
| displayName = fixDisplayName(displayName); | |
| const displayNameArguments = parseArguments(displayName); | |
| if (!compareArrays(procCodeArguments, displayNameArguments)) { | |
| console.warn(`displayName ${displayName} for ${procedureCode} has invalid arguments, ignoring it.`); | |
| displayName = procedureCode; | |
| } | |
| } else { | |
| displayName = procedureCode; | |
| } | |
| const vm = this.traps.vm; | |
| vm.addAddonBlock({ | |
| procedureCode, | |
| arguments: args, | |
| callback, | |
| color: '#29beb8', | |
| secondaryColor: '#3aa8a4', | |
| displayName | |
| }); | |
| if (!_firstAddBlockRan) { | |
| _firstAddBlockRan = true; | |
| this.traps.getBlockly().then(ScratchBlocks => { | |
| const BlockSvg = ScratchBlocks.BlockSvg; | |
| const oldUpdateColour = BlockSvg.prototype.updateColour; | |
| BlockSvg.prototype.updateColour = function (...args2) { | |
| // procedures_prototype also has a procedure code but we do not want to color them. | |
| if (!this.isInsertionMarker() && this.type === 'procedures_call') { | |
| const block = this.procCode_ && vm.runtime.getAddonBlock(this.procCode_); | |
| if (block) { | |
| this.colour_ = addonBlockColor.color; | |
| this.colourSecondary_ = addonBlockColor.secondaryColor; | |
| this.colourTertiary_ = addonBlockColor.tertiaryColor; | |
| this.customContextMenu = null; | |
| } | |
| } | |
| return oldUpdateColour.call(this, ...args2); | |
| }; | |
| const originalCreateAllInputs = ScratchBlocks.Blocks.procedures_call.createAllInputs_; | |
| ScratchBlocks.Blocks.procedures_call.createAllInputs_ = function (...args2) { | |
| const block = this.procCode_ && vm.runtime.getAddonBlock(this.procCode_); | |
| if (block && block.displayName) { | |
| const originalProcCode = this.procCode_; | |
| this.procCode_ = block.displayName; | |
| const ret = originalCreateAllInputs.call(this, ...args2); | |
| this.procCode_ = originalProcCode; | |
| return ret; | |
| } | |
| return originalCreateAllInputs.call(this, ...args2); | |
| }; | |
| if (vm.editingTarget) { | |
| vm.emitWorkspaceUpdate(); | |
| } | |
| }); | |
| } | |
| } | |
| getCustomBlock (procedureCode) { | |
| const vm = this.traps.vm; | |
| return vm.getAddonBlock(procedureCode); | |
| } | |
| getCustomBlockColor () { | |
| return addonBlockColor; | |
| } | |
| setCustomBlockColor (newColor) { | |
| Object.assign(addonBlockColor, newColor); | |
| } | |
| createBlockContextMenu (callback, {workspace = false, blocks = false, flyout = false, comments = false} = {}) { | |
| contextMenuCallbacks.push({addonId: this._id, callback, workspace, blocks, flyout, comments}); | |
| contextMenuCallbacks.sort((b, a) => ( | |
| CONTEXT_MENU_ORDER.indexOf(b.addonId) - CONTEXT_MENU_ORDER.indexOf(a.addonId) | |
| )); | |
| if (createdAnyBlockContextMenus) return; | |
| createdAnyBlockContextMenus = true; | |
| this.traps.getBlockly().then(ScratchBlocks => { | |
| const oldShow = ScratchBlocks.ContextMenu.show; | |
| ScratchBlocks.ContextMenu.show = function (event, items, rtl) { | |
| const gesture = ScratchBlocks.mainWorkspace.currentGesture_; | |
| // abbort the injection as we have no clue wtf this is | |
| if (!gesture) { | |
| oldShow.call(this, event, items, rtl); | |
| return; | |
| } | |
| const block = gesture.targetBlock_; | |
| // eslint-disable-next-line no-shadow | |
| for (const {callback, workspace, blocks, flyout, comments} of contextMenuCallbacks) { | |
| const injectMenu = | |
| // Workspace | |
| (workspace && !block && !gesture.flyout_ && !gesture.startBubble_) || | |
| // Block in workspace | |
| (blocks && block && !gesture.flyout_) || | |
| // Block in flyout | |
| (flyout && gesture.flyout_) || | |
| // Comments | |
| (comments && gesture.startBubble_); | |
| if (injectMenu) { | |
| try { | |
| items = callback(items, block); | |
| } catch (e) { | |
| console.error('Error while calling context menu callback: ', e); | |
| } | |
| } | |
| } | |
| oldShow.call(this, event, items, rtl); | |
| const blocklyContextMenu = ScratchBlocks.WidgetDiv.DIV.firstChild; | |
| items.forEach((item, i) => { | |
| if (i !== 0 && item.separator) { | |
| const itemElt = blocklyContextMenu.children[i]; | |
| itemElt.style.paddingTop = '2px'; | |
| itemElt.classList.add('sa-blockly-menu-item-border'); | |
| itemElt.style.borderTop = '1px solid hsla(0, 0%, 0%, 0.15)'; | |
| } | |
| }); | |
| }; | |
| }); | |
| } | |
| createEditorContextMenu (callback, options) { | |
| addContextMenu(this, callback, options); | |
| } | |
| copyImage (dataURL) { | |
| if (!navigator.clipboard.write) { | |
| return Promise.reject(new Error('Clipboard API not supported')); | |
| } | |
| const items = [ | |
| // eslint-disable-next-line no-undef | |
| new ClipboardItem({ | |
| 'image/png': dataURLToBlob(dataURL) | |
| }) | |
| ]; | |
| return navigator.clipboard.write(items); | |
| } | |
| scratchMessage (id) { | |
| return tabReduxInstance.state.locales.messages[id]; | |
| } | |
| scratchClass (...args) { | |
| const scratchClasses = getScratchClassNames(); | |
| const classes = []; | |
| for (const arg of args) { | |
| if (typeof arg === 'string') { | |
| for (const scratchClass of scratchClasses) { | |
| if (scratchClass.startsWith(`${arg}_`) && scratchClass.length === arg.length + 6) { | |
| classes.push(scratchClass); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| const options = args[args.length - 1]; | |
| if (typeof options === 'object') { | |
| const others = Array.isArray(options.others) ? options.others : [options.others]; | |
| for (const className of others) { | |
| classes.push(className); | |
| } | |
| } | |
| return classes.join(' '); | |
| } | |
| get editorMode () { | |
| return getEditorMode(); | |
| } | |
| displayNoneWhileDisabled (el) { | |
| el.classList.add(getDisplayNoneWhileDisabledClass(this._id)); | |
| } | |
| get direction () { | |
| return this.redux.state.locales.isRtl ? 'rtl' : 'ltr'; | |
| } | |
| createModal (title, {isOpen = false} = {}) { | |
| return modal.createEditorModal(this, title, {isOpen}); | |
| } | |
| confirm (...args) { | |
| return modal.confirm(this, ...args); | |
| } | |
| prompt (...args) { | |
| return modal.prompt(this, ...args); | |
| } | |
| } | |
| class Settings extends EventTargetShim { | |
| constructor (addonId, manifest) { | |
| super(); | |
| this._addonId = addonId; | |
| this._manifest = manifest; | |
| } | |
| get (id) { | |
| return SettingsStore.getAddonSetting(this._addonId, id); | |
| } | |
| } | |
| class Self extends EventTargetShim { | |
| constructor (id, getResource) { | |
| super(); | |
| this.id = id; | |
| this.disabled = false; | |
| this.getResource = getResource; | |
| } | |
| } | |
| class AddonRunner { | |
| constructor (id) { | |
| AddonRunner.instances.push(this); | |
| const manifest = addons[id]; | |
| this.id = id; | |
| this.manifest = manifest; | |
| this.messageCache = {}; | |
| this.loading = true; | |
| /** | |
| * @type {Record<string, unknown>} | |
| */ | |
| this.resources = null; | |
| this.publicAPI = { | |
| global, | |
| console, | |
| addon: { | |
| tab: new Tab(id), | |
| settings: new Settings(id, manifest), | |
| self: new Self(id, this.getResource.bind(this)) | |
| }, | |
| msg: this.msg.bind(this), | |
| safeMsg: this.safeMsg.bind(this) | |
| }; | |
| } | |
| _msg (key, vars, handler) { | |
| const namespacedKey = `${this.id}/${key}`; | |
| if (this.messageCache[namespacedKey]) { | |
| return this.messageCache[namespacedKey].format(vars); | |
| } | |
| let translation = addonMessages[namespacedKey]; | |
| if (!translation) { | |
| return namespacedKey; | |
| } | |
| if (handler) { | |
| translation = handler(translation); | |
| } | |
| const messageFormat = new IntlMessageFormat(translation, language); | |
| this.messageCache[namespacedKey] = messageFormat; | |
| return messageFormat.format(vars); | |
| } | |
| msg (key, vars) { | |
| return this._msg(key, vars, null); | |
| } | |
| safeMsg (key, vars) { | |
| return this._msg(key, vars, escapeHTML); | |
| } | |
| getResource (path) { | |
| const withoutSlash = path.substring(1); | |
| const url = this.resources[withoutSlash]; | |
| if (typeof url !== 'string') { | |
| throw new Error(`Unknown asset: ${path}`); | |
| } | |
| return url; | |
| } | |
| updateAllStyles () { | |
| conditionalStyles.updateAll(); | |
| this.updateCssVariables(); | |
| } | |
| updateCssVariables () { | |
| const addonId = kebabCaseToCamelCase(this.id); | |
| if (this.manifest.settings) { | |
| for (const setting of this.manifest.settings) { | |
| const settingId = setting.id; | |
| const cssProperty = `--${addonId}-${kebabCaseToCamelCase(settingId)}`; | |
| const value = this.publicAPI.addon.settings.get(settingId); | |
| document.documentElement.style.setProperty(cssProperty, value); | |
| } | |
| } | |
| if (this.manifest.customCssVariables) { | |
| for (const variable of this.manifest.customCssVariables) { | |
| const name = variable.name; | |
| const cssProperty = `--${addonId}-${name}`; | |
| const value = variable.value; | |
| const evaluated = this.evaluateCustomCssVariable(value); | |
| document.documentElement.style.setProperty(cssProperty, evaluated); | |
| } | |
| } | |
| } | |
| evaluateCustomCssVariable (variable) { | |
| if (typeof variable !== 'object' || variable === null) { | |
| return variable; | |
| } | |
| switch (variable.type) { | |
| case 'alphaBlend': { | |
| const opaqueSource = this.evaluateCustomCssVariable(variable.opaqueSource); | |
| const transparentSource = this.evaluateCustomCssVariable(variable.transparentSource); | |
| return textColorHelpers.alphaBlend(opaqueSource, transparentSource); | |
| } | |
| case 'alphaThreshold': { | |
| const source = this.evaluateCustomCssVariable(variable.source); | |
| const alpha = textColorHelpers.parseHex(source).a; | |
| const threshold = this.evaluateCustomCssVariable(variable.threshold) || 0.5; | |
| if (alpha >= threshold) { | |
| return this.evaluateCustomCssVariable(variable.opaque); | |
| } | |
| return this.evaluateCustomCssVariable(variable.transparent); | |
| } | |
| case 'brighten': { | |
| const source = this.evaluateCustomCssVariable(variable.source); | |
| return textColorHelpers.brighten(source, variable); | |
| } | |
| case 'makeHsv': { | |
| const h = this.evaluateCustomCssVariable(variable.h); | |
| const s = this.evaluateCustomCssVariable(variable.s); | |
| const v = this.evaluateCustomCssVariable(variable.v); | |
| return textColorHelpers.makeHsv(h, s, v); | |
| } | |
| case 'map': { | |
| return variable.options[this.evaluateCustomCssVariable(variable.source)]; | |
| } | |
| case 'multiply': { | |
| const hex = this.evaluateCustomCssVariable(variable.source); | |
| return textColorHelpers.multiply(hex, variable); | |
| } | |
| case 'recolorFilter': { | |
| const source = this.evaluateCustomCssVariable(variable.source); | |
| return textColorHelpers.recolorFilter(source); | |
| } | |
| case 'settingValue': { | |
| return this.publicAPI.addon.settings.get(variable.settingId); | |
| } | |
| case 'textColor': { | |
| const hex = this.evaluateCustomCssVariable(variable.source); | |
| const black = this.evaluateCustomCssVariable(variable.black); | |
| const white = this.evaluateCustomCssVariable(variable.white); | |
| const threshold = this.evaluateCustomCssVariable(variable.threshold); | |
| return textColorHelpers.textColor(hex, black, white, threshold); | |
| } | |
| } | |
| console.warn(`Unknown customCssVariable`, variable); | |
| return '#000000'; | |
| } | |
| settingsChanged () { | |
| this.updateAllStyles(); | |
| this.publicAPI.addon.settings.dispatchEvent(new CustomEvent('change')); | |
| } | |
| dynamicEnable () { | |
| if (this.loading) { | |
| return; | |
| } | |
| // This order is important. We need to update styles before calling the addon's dynamic | |
| // toggle event. We also need to update `disabled` before we can update styles because | |
| // the ConditionalStyle callbacks are implemented using the API. | |
| this.publicAPI.addon.self.disabled = false; | |
| this.updateAllStyles(); | |
| this.publicAPI.addon.self.dispatchEvent(new CustomEvent('reenabled')); | |
| } | |
| dynamicDisable () { | |
| if (this.loading) { | |
| return; | |
| } | |
| // See comment in dynamicEnable(). | |
| this.publicAPI.addon.self.disabled = true; | |
| this.updateAllStyles(); | |
| this.publicAPI.addon.self.dispatchEvent(new CustomEvent('disabled')); | |
| } | |
| async run () { | |
| if (this.manifest.editorOnly) { | |
| await untilInEditor(); | |
| } | |
| const mod = await addonEntries[this.id](); | |
| this.resources = mod.resources; | |
| if (!this.manifest.noTranslations) { | |
| await addonMessagesPromise; | |
| } | |
| // Multiply by big number because the first userstyle is + 0, second is + 1, third is + 2, etc. | |
| // This number just has to be larger than the maximum number of userstyles in a single addon. | |
| const baseStylePrecedence = getPrecedence(this.id) * 100; | |
| if (this.manifest.userstyles) { | |
| for (let i = 0; i < this.manifest.userstyles.length; i++) { | |
| const userstyle = this.manifest.userstyles[i]; | |
| const userstylePrecedence = baseStylePrecedence + i; | |
| const userstyleCondition = () => ( | |
| !this.publicAPI.addon.self.disabled && | |
| SettingsStore.evaluateCondition(this.id, userstyle.if) | |
| ); | |
| for (const [moduleId, cssText] of this.resources[userstyle.url]) { | |
| const sheet = conditionalStyles.create(moduleId, cssText); | |
| sheet.addDependent(this.id, userstylePrecedence, userstyleCondition); | |
| } | |
| } | |
| } | |
| const disabledCSS = `.${getDisplayNoneWhileDisabledClass(this.id)}{display:none !important;}`; | |
| const disabledStylesheet = conditionalStyles.create(`_disabled/${this.id}`, disabledCSS); | |
| disabledStylesheet.addDependent(this.id, baseStylePrecedence, () => this.publicAPI.addon.self.disabled); | |
| this.updateCssVariables(); | |
| if (this.manifest.userscripts) { | |
| for (const userscript of this.manifest.userscripts) { | |
| if (!SettingsStore.evaluateCondition(userscript.if)) { | |
| continue; | |
| } | |
| const fn = this.resources[userscript.url]; | |
| fn(this.publicAPI); | |
| } | |
| } | |
| this.loading = false; | |
| } | |
| } | |
| AddonRunner.instances = []; | |
| const runAddon = addonId => { | |
| const runner = new AddonRunner(addonId); | |
| runner.run(); | |
| }; | |
| let oldMode = getEditorMode(); | |
| const emitUrlChange = () => { | |
| // In Scratch, URL changes usually mean someone went from editor to fullscreen or something like that. | |
| // This is not the case in TW -- the URL can change for many other reasons that addons probably aren't prepared | |
| // to handle. | |
| const newMode = getEditorMode(); | |
| if (newMode !== oldMode) { | |
| oldMode = newMode; | |
| setTimeout(() => { | |
| for (const addon of AddonRunner.instances) { | |
| addon.publicAPI.addon.tab.dispatchEvent(new CustomEvent('urlChange')); | |
| } | |
| }); | |
| } | |
| }; | |
| const originalReplaceState = history.replaceState; | |
| history.replaceState = function (...args) { | |
| originalReplaceState.apply(this, args); | |
| emitUrlChange(); | |
| }; | |
| const originalPushState = history.pushState; | |
| history.pushState = function (...args) { | |
| originalPushState.apply(this, args); | |
| emitUrlChange(); | |
| }; | |
| SettingsStore.addEventListener('addon-changed', e => { | |
| const addonId = e.detail.addonId; | |
| const runner = AddonRunner.instances.find(i => i.id === addonId); | |
| if (runner) { | |
| runner.settingsChanged(); | |
| } | |
| if (e.detail.dynamicEnable) { | |
| if (runner) { | |
| runner.dynamicEnable(); | |
| } else { | |
| runAddon(addonId); | |
| } | |
| } else if (e.detail.dynamicDisable) { | |
| if (runner) { | |
| runner.dynamicDisable(); | |
| } | |
| } | |
| }); | |
| for (const id of Object.keys(addons)) { | |
| if (!SettingsStore.getAddonEnabled(id)) { | |
| continue; | |
| } | |
| runAddon(id); | |
| } | |