soiz1's picture
Upload 2891 files
6bcb42f verified
/**
* 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);
}