/** * 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 . */ 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} */ 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); }