Spaces:
Build error
Build error
export default async function ({ addon, console, msg }) { | |
// The basic premise of how this addon works is relative simple. | |
// scratch-gui renders the sprite selectors and asset selectors in a hierarchy like this: | |
// <SelectorHOC> | |
// <SpriteSelectorItem /> | |
// <SpriteSelectorItem /> | |
// <SpriteSelectorItem /> | |
// <SpriteSelectorItem /> | |
// ... | |
// </SelectorHOC> | |
// It's obviously more complicated than that, but there are two important parts: | |
// SelectorHOC - We override this to change which items are displayed | |
// SpriteSelectorItem - We override this to change how items are displayed. | |
// Folders are just items rendered differently | |
// These two components communicate through the `name` property of the items. | |
// We touch some things on the VM to make dragging items work properly. | |
const REACT_INTERNAL_PREFIX = "__reactInternalInstance$"; | |
const TYPE_SPRITES = 1; | |
const TYPE_ASSETS = 2; | |
// We run too early, will be set later | |
let vm; | |
let reactInternalKey; | |
let currentSpriteFolder; | |
let currentAssetFolder; | |
let currentSpriteItems; | |
let currentAssetItems; | |
const DIVIDER = "//"; | |
/** | |
* getFolderFromName("B") === null | |
* getFolderFromName("A//b") === "A" | |
*/ | |
const getFolderFromName = (name) => { | |
const idx = name.indexOf(DIVIDER); | |
if (idx === -1 || idx === 0) { | |
return null; | |
} | |
return name.substr(0, idx); | |
}; | |
/** | |
* getNameWithoutFolder("B") === "B" | |
* getNameWithoutFolder("A//b") === "b" | |
*/ | |
const getNameWithoutFolder = (name) => { | |
const idx = name.indexOf(DIVIDER); | |
if (idx === -1 || idx === 0) { | |
return name; | |
} | |
return name.substr(idx + DIVIDER.length); | |
}; | |
/** | |
* setFolderOfName("B", "y") === "y//B" | |
* setFolderOfName("c//B", "y") === "y//B" | |
* setFolderOfName("B", null) === "B" | |
* setFolderOfName("c//B", null) === "B" | |
*/ | |
const setFolderOfName = (name, folder) => { | |
const basename = getNameWithoutFolder(name); | |
if (folder) { | |
return `${folder}${DIVIDER}${basename}`; | |
} | |
return basename; | |
}; | |
const isValidFolderName = (name) => { | |
return !name.includes(DIVIDER) && !name.endsWith("/"); | |
}; | |
const RESERVED_NAMES = ["_mouse_", "_stage_", "_edge_", "_myself_", "_random_"]; | |
const ensureNotReserved = (name) => { | |
if (name === "") return "2"; | |
if (RESERVED_NAMES.includes(name)) return `${name}2`; | |
return name; | |
}; | |
const getSortableHOCFromElement = (el) => { | |
const nearestSpriteSelector = el.closest("[class*='sprite-selector_sprite-selector']"); | |
if (nearestSpriteSelector) { | |
return nearestSpriteSelector[reactInternalKey].child.sibling.child.stateNode; | |
} | |
const nearestAssetPanelWrapper = el.closest('[class*="asset-panel_wrapper"]'); | |
if (nearestAssetPanelWrapper) { | |
return nearestAssetPanelWrapper[reactInternalKey].child.child.stateNode; | |
} | |
throw new Error("cannot find SortableHOC"); | |
}; | |
const getBackpackFromElement = (el) => { | |
const gui = el.closest('[class*="gui_editor-wrapper"]'); | |
if (!gui) throw new Error("cannot find Backpack"); | |
return gui[reactInternalKey].child.sibling.child.child.stateNode; | |
}; | |
const clamp = (n, min, max) => { | |
return Math.min(Math.max(n, min), max); | |
}; | |
/** | |
* @typedef {Object} ItemData | |
* @property {string} realName | |
* @property {number} realIndex | |
* @property {string} inFolder | |
* @property {string} folder | |
* @property {boolean} folderOpen | |
*/ | |
/** | |
* @returns {ItemData|null} | |
*/ | |
const getItemData = (item) => { | |
if (item && item.name && typeof item.name === "object") { | |
return item.name; | |
} | |
return null; | |
}; | |
const openFolderAsset = { | |
assetId: "&__sa_folders_folder", | |
encodeDataURI() { | |
// Doesn't actually need to be a data: URI | |
return addon.self.getResource("/folder.svg") /* rewritten by pull.js */; | |
}, | |
}; | |
// https://github.com/LLK/scratch-gui/blob/develop/src/components/asset-panel/icon--sound.svg | |
const imageIconSource = `<?xml version="1.0" encoding="UTF-8"?> | |
<svg width="100px" height="100px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> | |
<g id="Sound" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> | |
<path d="M12.4785058,12.6666667 C12.3144947,12.6666667 12.1458852,12.6272044 11.9926038,12.5440517 C11.537358,12.2960031 11.3856094,11.7562156 11.6553847,11.3376335 C12.1688774,10.5371131 12.1688774,9.54491867 11.6553847,8.74580756 C11.3856094,8.32581618 11.537358,7.78602861 11.9926038,7.53798001 C12.452448,7.29275014 13.0379829,7.43086811 13.3046926,7.84804076 C14.1737981,9.20103311 14.1737981,10.8809986 13.3046926,12.233991 C13.1268862,12.5130457 12.806528,12.6666667 12.4785058,12.6666667 Z M15.3806784,13.8333333 C15.2408902,13.8333333 15.0958763,13.796281 14.9665396,13.7182064 C14.5785295,13.485306 14.4491928,12.9784829 14.6791247,12.5854634 C15.5949331,11.0160321 15.5949331,9.065491 14.6791247,7.49738299 C14.4491928,7.10436352 14.5785295,6.59621712 14.9665396,6.36331669 C15.3558562,6.13438616 15.8549129,6.26274605 16.0848448,6.65444223 C17.3050517,8.74260632 17.3050517,11.3389168 16.0848448,13.4270809 C15.9319924,13.6890939 15.6602547,13.8333333 15.3806784,13.8333333 Z M10.3043478,5.62501557 L10.3043478,13.873675 C10.3043478,14.850934 9.10969849,15.3625101 8.36478311,14.7038052 L6.7566013,13.2797607 C6.18712394,12.7762834 5.44499329,12.4968737 4.67362297,12.4968737 L4.3923652,12.4968737 C3.62377961,12.4968737 3,11.8935108 3,11.1470686 L3,8.36646989 C3,7.62137743 3.62377961,7.01666471 4.3923652,7.01666471 L4.65830695,7.01666471 C5.42967727,7.01666471 6.17180792,6.73725504 6.74128529,6.23377771 L8.36478311,4.79623519 C9.10969849,4.13753026 10.3043478,4.64910643 10.3043478,5.62501557 Z" id="Combined-Shape" fill="#575E75"></path> | |
</g> | |
</svg>`; | |
const soundIconHref = `data:image/svg+xml;base64,${btoa(imageIconSource)}`; | |
let folderColorStylesheet = null; | |
const folderColors = Object.create(null); | |
const getFolderColorClass = (folderName) => { | |
const mulberry32 = (a) => { | |
// https://stackoverflow.com/a/47593316 | |
return function () { | |
var t = (a += 0x6d2b79f5); | |
t = Math.imul(t ^ (t >>> 15), t | 1); | |
t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
}; | |
}; | |
const hashCode = (str) => { | |
// Based on Java's String.hashCode | |
// https://hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/share/classes/java/lang/String.java#l1452 | |
let hash = 0; | |
for (let i = 0; i < str.length; i++) { | |
hash = 31 * hash + str.charCodeAt(i); | |
hash = hash | 0; | |
} | |
return hash; | |
}; | |
const random = (str) => { | |
const seed = hashCode(str); | |
const rng = mulberry32(seed); | |
// Run RNG a few times to get more random numbers, otherwise similar seeds tend to give somewhat similar results | |
rng(); | |
rng(); | |
rng(); | |
rng(); | |
return rng(); | |
}; | |
if (!folderColors[folderName]) { | |
if (!folderColorStylesheet) { | |
folderColorStylesheet = document.createElement("style"); | |
document.head.appendChild(folderColorStylesheet); | |
} | |
const hue = random(folderName) * 360; | |
const color = `hsla(${hue}deg, 100%, 85%, 0.5)`; | |
const id = Object.keys(folderColors).length; | |
const className = `sa-folders-color-${id}`; | |
folderColors[folderName] = className; | |
folderColorStylesheet.textContent += `.${className}{background-color:${color} !important;}`; | |
folderColorStylesheet.textContent += `.${className}[class*="sprite-selector_raised"]:not([class*="sa-folders-folder"]){background-color:hsla(${hue}deg, 100%, 77%, 1) !important;}`; | |
} | |
return folderColors[folderName]; | |
}; | |
const fixOrderOfItemsInFolders = (items) => { | |
const folders = Object.create(null); | |
const result = []; | |
for (const item of items) { | |
const name = item.getName ? item.getName() : item.name; | |
const folder = getFolderFromName(name); | |
if (typeof folder === "string") { | |
if (!folders[folder]) { | |
folders[folder] = []; | |
result.push(folders[folder]); | |
} | |
folders[folder].push(item); | |
} else { | |
result.push(item); | |
} | |
} | |
const flatResult = result.flat(); | |
for (let i = 0; i < items.length; i++) { | |
if (result[i] !== items[i]) { | |
return { items: flatResult, changed: true }; | |
} | |
} | |
return { items: flatResult, changed: false }; | |
}; | |
const fixTargetOrder = () => { | |
const { items, changed } = fixOrderOfItemsInFolders(vm.runtime.targets); | |
if (changed) { | |
vm.runtime.targets = items; | |
vm.emitTargetsUpdate(); | |
} | |
}; | |
const fixCostumeOrder = (target = vm.editingTarget) => { | |
const { items, changed } = fixOrderOfItemsInFolders(target.sprite.costumes); | |
if (changed) { | |
target.sprite.costumes = items; | |
vm.emitTargetsUpdate(); | |
} | |
}; | |
const fixSoundOrder = (target = vm.editingTarget) => { | |
const { items, changed } = fixOrderOfItemsInFolders(target.sprite.sounds); | |
if (changed) { | |
target.sprite.sounds = items; | |
vm.emitTargetsUpdate(); | |
} | |
}; | |
const verifySortableHOC = (sortableHOCInstance) => { | |
const SortableHOC = sortableHOCInstance.constructor; | |
if ( | |
Array.isArray(sortableHOCInstance.props.items) && | |
(typeof sortableHOCInstance.props.selectedId === "string" || | |
typeof sortableHOCInstance.props.selectedItemIndex === "number") && | |
typeof sortableHOCInstance.containerBox !== "undefined" && | |
typeof SortableHOC.prototype.componentDidMount === "undefined" && | |
typeof SortableHOC.prototype.componentDidUpdate === "undefined" && | |
typeof SortableHOC.prototype.handleAddSortable === "function" && | |
typeof SortableHOC.prototype.handleRemoveSortable === "function" && | |
typeof SortableHOC.prototype.setRef === "function" | |
) | |
return; | |
throw new Error("Can not comprehend SortableHOC"); | |
}; | |
const verifySpriteSelectorItem = (spriteSelectorItemInstance) => { | |
const SpriteSelectorItem = spriteSelectorItemInstance.constructor; | |
if ( | |
typeof spriteSelectorItemInstance.props.asset === "object" && | |
typeof spriteSelectorItemInstance.props.name === "string" && | |
typeof spriteSelectorItemInstance.props.dragType === "string" && | |
typeof SpriteSelectorItem.prototype.handleClick === "function" && | |
typeof SpriteSelectorItem.prototype.setRef === "function" && | |
typeof SpriteSelectorItem.prototype.handleDrag === "function" && | |
typeof SpriteSelectorItem.prototype.handleDragEnd === "function" && | |
typeof SpriteSelectorItem.prototype.handleDelete === "function" && | |
typeof SpriteSelectorItem.prototype.handleDuplicate === "function" && | |
typeof SpriteSelectorItem.prototype.handleExport === "function" | |
) | |
return; | |
throw new Error("Can not comprehend SpriteSelectorItem"); | |
}; | |
const verifyVM = (vm) => { | |
const target = vm.runtime.targets[0]; | |
if ( | |
typeof vm.installTargets === "function" && | |
typeof vm.reorderTarget === "function" && | |
typeof target.reorderCostume === "function" && | |
typeof target.reorderSound === "function" && | |
typeof target.addCostume === "function" && | |
typeof target.addSound === "function" | |
) | |
return; | |
throw new Error("Can not comprehend VM"); | |
}; | |
const verifyBackpack = (backpackInstance) => { | |
const Backpack = backpackInstance.constructor; | |
if ( | |
typeof Backpack.prototype.handleDrop === "function" && | |
typeof Backpack.prototype.componentDidUpdate === "undefined" | |
) { | |
return; | |
} | |
throw new Error("Can not comprehend Backpack"); | |
}; | |
class Cache { | |
constructor() { | |
this.cache = new Map(); | |
this.usedThisTick = new Set(); | |
} | |
has(id) { | |
return this.cache.has(id); | |
} | |
get(id) { | |
this.usedThisTick.add(id); | |
return this.cache.get(id); | |
} | |
set(id, value) { | |
this.usedThisTick.add(id); | |
this.cache.set(id, value); | |
} | |
startTick() { | |
this.usedThisTick.clear(); | |
} | |
endTick() { | |
for (const id of Array.from(this.cache.keys())) { | |
if (!this.usedThisTick.has(id)) { | |
this.cache.delete(id); | |
} | |
} | |
} | |
clear() { | |
this.usedThisTick.clear(); | |
this.cache.clear(); | |
} | |
} | |
const patchSortableHOC = (SortableHOC, type) => { | |
// SortableHOC should be: https://github.com/LLK/scratch-gui/blob/29d9851778febe4e69fa5111bf7559160611e366/src/lib/sortable-hoc.jsx#L8 | |
const itemCache = new Cache(); | |
const folderItemCache = new Cache(); | |
const folderAssetCache = new Cache(); | |
const PREVIEW_SIZE = 80; | |
const PREVIEW_POSITIONS = [ | |
// x, y | |
[0, 0], | |
[PREVIEW_SIZE / 2, 0], | |
[0, PREVIEW_SIZE / 2], | |
[PREVIEW_SIZE / 2, PREVIEW_SIZE / 2], | |
]; | |
const createFolderPreview = (items) => { | |
// Directly generate a string instead of using DOM API for performance as we deal with very large inlined images | |
// Because the result is only used as an img src, XSS shouldn't be a concern | |
let result = `data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" width="${PREVIEW_SIZE}" height="${PREVIEW_SIZE}">`; | |
for (let i = 0; i < Math.min(PREVIEW_POSITIONS.length, items.length); i++) { | |
const item = items[i]; | |
const width = PREVIEW_SIZE / 2; | |
const height = PREVIEW_SIZE / 2; | |
const [x, y] = PREVIEW_POSITIONS[i]; | |
let src; | |
if (item.asset) { | |
// TW: We can be 100% certain that escaping here is unnecessary | |
src = item.asset.encodeDataURI(); | |
} else if (item.costume && item.costume.asset) { | |
src = item.costume.asset.encodeDataURI(); | |
} else if (item.url) { | |
src = soundIconHref; | |
} | |
if (src) { | |
result += `<image width="${width}" height="${height}" x="${x}" y="${y}" href="${src}"/>`; | |
} | |
} | |
result += "</svg>"; | |
return result; | |
}; | |
const getUniqueIdOfFolderItems = (items) => { | |
let id = "sa_folder&&"; | |
for (let i = 0; i < Math.min(PREVIEW_POSITIONS.length, items.length); i++) { | |
const item = items[i]; | |
if (item.asset) { | |
id += item.asset.assetId; | |
} else if (item.costume && item.costume.asset) { | |
id += item.costume.asset.assetId; | |
} else if (item.url) { | |
id += item.url; | |
} | |
id += "&&"; | |
} | |
return id; | |
}; | |
const processItems = (openFolders, props) => { | |
const processItem = (item) => { | |
const itemId = item.name; | |
let newItem; | |
let itemData; | |
if (itemCache.has(itemId)) { | |
newItem = itemCache.get(itemId); | |
itemData = newItem.name; | |
} else { | |
itemData = { | |
toString() { | |
return `_${item.name}`; | |
}, | |
}; | |
newItem = {}; | |
itemCache.set(itemId, newItem); | |
} | |
const itemFolderName = getFolderFromName(item.name); | |
Object.assign(newItem, item); | |
itemData.realName = item.name; | |
itemData.realIndex = i; | |
itemData.inFolder = itemFolderName; | |
newItem.name = itemData; | |
return { | |
newItem, | |
itemData, | |
}; | |
}; | |
itemCache.startTick(); | |
folderItemCache.startTick(); | |
folderAssetCache.startTick(); | |
const folderOccurrences = new Map(); | |
const items = []; | |
const result = { | |
items, | |
}; | |
let i = 0; | |
while (i < props.items.length) { | |
const item = props.items[i]; | |
const folderName = getFolderFromName(item.name); | |
if (folderName === null) { | |
items.push(processItem(item).newItem); | |
if (type === TYPE_ASSETS) { | |
const isSelected = props.selectedItemIndex === i; | |
if (isSelected) { | |
result.selectedItemIndex = items.length - 1; | |
} | |
} | |
} else { | |
const isOpen = openFolders.indexOf(folderName) !== -1; | |
const folderItems = []; | |
while (i < props.items.length) { | |
const childItem = props.items[i]; | |
const processedItem = processItem(childItem); | |
if (getFolderFromName(childItem.name) !== folderName) { | |
break; | |
} | |
folderItems.push(processedItem.newItem); | |
if (type === TYPE_ASSETS) { | |
const isSelected = props.selectedItemIndex === i; | |
if (isSelected) { | |
if (isOpen) { | |
result.selectedItemIndex = items.length + folderItems.length; | |
} else { | |
result.selectedItemIndex = -1; | |
} | |
} | |
} | |
i++; | |
} | |
i--; | |
const occurrence = folderOccurrences.get(folderName) || 0; | |
folderOccurrences.set(folderName, occurrence + 1); | |
const baseUniqueId = getUniqueIdOfFolderItems(folderItems); | |
const itemUniqueId = `${isOpen}&${occurrence}&${folderName}&${baseUniqueId}&`; | |
const reactKey = `&__${occurrence}_${folderName}`; | |
const assetUniqueId = baseUniqueId; | |
let folderItem; | |
let folderData; | |
if (folderItemCache.has(itemUniqueId)) { | |
folderItem = folderItemCache.get(itemUniqueId); | |
folderData = folderItem.name; | |
} else { | |
folderItem = { | |
// Can be used as a react key | |
id: { | |
toString() { | |
return reactKey; | |
}, | |
}, | |
}; | |
folderData = { | |
// Can be used as a react key | |
toString() { | |
return reactKey; | |
}, | |
}; | |
folderItemCache.set(itemUniqueId, folderItem); | |
} | |
folderData.folder = folderName; | |
folderData.folderOpen = isOpen; | |
folderItem.items = folderItems; | |
folderItem.name = folderData; | |
let folderAsset; | |
if (isOpen) { | |
folderAsset = openFolderAsset; | |
} else { | |
if (folderAssetCache.has(assetUniqueId)) { | |
folderAsset = folderAssetCache.get(assetUniqueId); | |
} else { | |
folderAsset = { | |
assetId: assetUniqueId, | |
encodeDataURI() { | |
return createFolderPreview(folderItems); | |
}, | |
}; | |
folderAssetCache.set(assetUniqueId, folderAsset); | |
} | |
} | |
if (type === TYPE_SPRITES) { | |
if (!folderItem.costume) folderItem.costume = {}; | |
folderItem.costume.asset = folderAsset; | |
// For sprite items, `id` is used as the drag payload and toString is used as a React key | |
if (!folderItem.id) folderItem.id = {}; | |
folderItem.id.sa_folder_items = folderItems; | |
folderItem.id.toString = () => reactKey; | |
} else { | |
folderItem.asset = folderAsset; | |
if (!folderItem.dragPayload) folderItem.dragPayload = {}; | |
folderItem.dragPayload.sa_folder_items = folderItems; | |
} | |
items.push(folderItem); | |
if (isOpen) { | |
for (const item of folderItems) { | |
items.push(item); | |
} | |
} | |
} | |
i++; | |
} | |
itemCache.endTick(); | |
folderItemCache.endTick(); | |
folderAssetCache.endTick(); | |
return result; | |
}; | |
const getSelectedItem = (sortable) => { | |
if (type === TYPE_SPRITES) { | |
const selectedItem = sortable.props.items.find((i) => i.id === sortable.props.selectedId); | |
return selectedItem; | |
} else if (type === TYPE_ASSETS) { | |
const selectedItem = sortable.props.items[sortable.props.selectedItemIndex]; | |
return selectedItem; | |
} | |
return null; | |
}; | |
SortableHOC.prototype.saInitialSetup = function () { | |
itemCache.clear(); | |
folderItemCache.clear(); | |
folderAssetCache.clear(); | |
const folders = []; | |
const selectedItem = getSelectedItem(this); | |
if (selectedItem && !selectedItem.isStage) { | |
const folder = getFolderFromName(selectedItem.name); | |
folders.push(folder); | |
if (type === TYPE_SPRITES) { | |
currentSpriteFolder = folder; | |
} else if (type === TYPE_ASSETS) { | |
currentAssetFolder = folder; | |
} | |
} | |
this.setState({ | |
folders, | |
}); | |
}; | |
SortableHOC.prototype.componentDidMount = function () { | |
// Do part of componentDidUpdate on mount as well | |
const selectedItem = getSelectedItem(this); | |
if (selectedItem) { | |
const folder = getFolderFromName(selectedItem.name); | |
if (type === TYPE_SPRITES) { | |
currentSpriteFolder = folder; | |
} else if (type === TYPE_ASSETS) { | |
currentAssetFolder = folder; | |
} | |
} | |
this.saInitialSetup(); | |
}; | |
SortableHOC.prototype.componentDidUpdate = function (prevProps, prevState) { | |
const selectedItem = getSelectedItem(this); | |
if (selectedItem) { | |
const folder = getFolderFromName(selectedItem.name); | |
const currentFolder = this.state.folders.includes(folder) ? folder : null; | |
if (type === TYPE_SPRITES) { | |
currentSpriteFolder = currentFolder; | |
} else if (type === TYPE_ASSETS) { | |
currentAssetFolder = currentFolder; | |
} | |
let selectedItemChanged; | |
if (this.props.selectedId) { | |
selectedItemChanged = this.props.selectedId !== prevProps.selectedId; | |
} else { | |
selectedItemChanged = | |
this.props.items[this.props.selectedItemIndex] && | |
prevProps.items[prevProps.selectedItemIndex] && | |
this.props.items[this.props.selectedItemIndex].name !== prevProps.items[prevProps.selectedItemIndex].name; | |
} | |
if (selectedItemChanged) { | |
if (!selectedItem.isStage) { | |
if (typeof folder === "string" && !this.state.folders.includes(folder)) { | |
this.setState((prevState) => ({ | |
folders: [...prevState.folders, folder], | |
})); | |
} | |
} | |
} | |
} | |
}; | |
const originalSortableHOCRender = SortableHOC.prototype.render; | |
SortableHOC.prototype.render = function () { | |
const originalProps = this.props; | |
this.props = { | |
...this.props, | |
...processItems((this.state && this.state.folders) || [], this.props), | |
}; | |
if (type === TYPE_SPRITES) { | |
currentSpriteItems = this.props.items; | |
} else if (type === TYPE_ASSETS) { | |
currentAssetItems = this.props.items; | |
} | |
const result = originalSortableHOCRender.call(this); | |
this.props = originalProps; | |
return result; | |
}; | |
}; | |
const getAllFolders = (component) => { | |
const result = new Set(); | |
let items; | |
if (component.props.dragType === "SPRITE") { | |
items = currentSpriteItems; | |
} else { | |
items = currentAssetItems; | |
} | |
for (const item of items) { | |
const data = getItemData(item); | |
if (typeof data.folder === "string") { | |
result.add(data.folder); | |
} | |
} | |
return Array.from(result); | |
}; | |
const isFolderOpen = (component, folder) => { | |
const sortableHOCInstance = getSortableHOCFromElement(component.ref); | |
const folders = (sortableHOCInstance.state && sortableHOCInstance.state.folders) || []; | |
return folders.includes(folder); | |
}; | |
const setFolderOpen = (component, folder, open) => { | |
const sortableHOCInstance = getSortableHOCFromElement(component.ref); | |
sortableHOCInstance.setState((prevState) => { | |
let folders = (prevState && prevState.folders) || []; | |
folders = folders.filter((i) => i !== folder); | |
if (open) { | |
return { | |
folders: [...folders, folder], | |
}; | |
} | |
return { | |
folders, | |
}; | |
}); | |
}; | |
addon.tab.createEditorContextMenu((ctxType, ctx) => { | |
if (ctxType !== "sprite" && ctxType !== "costume" && ctxType !== "sound") return; | |
const component = ctx.target[addon.tab.traps.getInternalKey(ctx.target)].return.return.return.stateNode; | |
const data = getItemData(component.props); | |
if (!data) return; | |
if (typeof data.folder === "string") { | |
ctx.target.setAttribute("sa-folders-context-type", "folder"); | |
const renameItems = (newName) => { | |
const isOpen = isFolderOpen(component, data.folder); | |
setFolderOpen(component, data.folder, false); | |
if (isOpen && typeof newName === "string") { | |
setFolderOpen(component, newName, true); | |
} | |
if (component.props.dragType === "SPRITE") { | |
for (const target of vm.runtime.targets) { | |
if (target.isOriginal) { | |
if (getFolderFromName(target.getName()) === data.folder) { | |
vm.renameSprite(target.id, ensureNotReserved(setFolderOfName(target.getName(), newName))); | |
} | |
} | |
} | |
vm.emitWorkspaceUpdate(); | |
fixTargetOrder(); | |
} else if (component.props.dragType === "COSTUME") { | |
for (let i = 0; i < vm.editingTarget.sprite.costumes.length; i++) { | |
const costume = vm.editingTarget.sprite.costumes[i]; | |
if (getFolderFromName(costume.name) === data.folder) { | |
vm.renameCostume(i, setFolderOfName(costume.name, newName)); | |
} | |
} | |
fixCostumeOrder(); | |
} else if (component.props.dragType === "SOUND") { | |
for (let i = 0; i < vm.editingTarget.sprite.sounds.length; i++) { | |
const sound = vm.editingTarget.sprite.sounds[i]; | |
if (getFolderFromName(sound.name) === data.folder) { | |
vm.renameSound(i, setFolderOfName(sound.name, newName)); | |
} | |
} | |
fixSoundOrder(); | |
} | |
}; | |
const renameFolder = async () => { | |
let newName = await addon.tab.prompt( | |
msg("rename-folder-prompt-title"), | |
msg("rename-folder-prompt"), | |
data.folder, | |
{ useEditorClasses: true } | |
); | |
// Prompt cancelled, do not rename | |
if (newName === null) { | |
return; | |
} | |
if (!isValidFolderName(newName)) { | |
alert(msg("name-not-allowed")); | |
return; | |
} | |
// Empty name will remove the folder | |
if (!newName) { | |
newName = null; | |
} | |
renameItems(newName); | |
}; | |
const removeFolder = () => { | |
renameItems(null); | |
}; | |
return [ | |
{ | |
className: "sa-folders-rename-folder", | |
label: msg("rename-folder"), | |
callback: renameFolder, | |
position: "assetContextMenuAfterDelete", | |
order: 10, | |
}, | |
{ | |
className: "sa-folders-remove-folder", | |
label: msg("remove-folder"), | |
callback: removeFolder, | |
position: "assetContextMenuAfterDelete", | |
order: 11, | |
}, | |
]; | |
} else { | |
ctx.target.setAttribute("sa-folders-context-type", "asset"); | |
const setFolder = (folder) => { | |
if (component.props.dragType === "SPRITE") { | |
const target = vm.runtime.getTargetById(component.props.id); | |
vm.renameSprite(component.props.id, ensureNotReserved(setFolderOfName(target.getName(), folder))); | |
fixTargetOrder(); | |
vm.emitWorkspaceUpdate(); | |
} else if (component.props.dragType === "COSTUME") { | |
const data = getItemData(component.props); | |
const index = data.realIndex; | |
const asset = vm.editingTarget.sprite.costumes[index]; | |
vm.renameCostume(vm.editingTarget.sprite.costumes.indexOf(asset), setFolderOfName(asset.name, folder)); | |
fixCostumeOrder(); | |
} else if (component.props.dragType === "SOUND") { | |
const data = getItemData(component.props); | |
const index = data.realIndex; | |
const asset = vm.editingTarget.sprite.sounds[index]; | |
vm.renameSound(vm.editingTarget.sprite.sounds.indexOf(asset), setFolderOfName(asset.name, folder)); | |
fixSoundOrder(); | |
} | |
}; | |
const createFolder = async () => { | |
const name = await addon.tab.prompt( | |
msg("name-prompt-title"), | |
msg("name-prompt"), | |
getNameWithoutFolder(data.realName), | |
{ useEditorClasses: true } | |
); | |
if (name === null) { | |
return; | |
} | |
if (!isValidFolderName(name)) { | |
alert(msg("name-not-allowed")); | |
return; | |
} | |
setFolder(name); | |
}; | |
const base = [ | |
{ | |
border: true, | |
className: "sa-folders-create-folder", | |
label: msg("create-folder"), | |
callback: createFolder, | |
position: "assetContextMenuAfterDelete", | |
order: 13, | |
}, | |
]; | |
const currentFolder = data.inFolder; | |
if (typeof currentFolder === "string") { | |
base.push({ | |
className: "sa-folders-remove-from-folder", | |
label: msg("remove-from-folder"), | |
callback: () => setFolder(null), | |
position: "assetContextMenuAfterDelete", | |
order: 14, | |
}); | |
} | |
return base.concat( | |
getAllFolders(component) | |
.filter((folder) => folder !== currentFolder) | |
.map((folder, i) => { | |
return { | |
className: "sa-folders-add-to-folder", | |
label: msg("add-to-folder", { | |
folder, | |
}), | |
callback: () => setFolder(folder), | |
position: "assetContextMenuAfterDelete", | |
order: 20 + i, | |
}; | |
}) | |
); | |
} | |
}); | |
const patchSpriteSelectorItem = (SpriteSelectorItem) => { | |
for (const method of ["handleDelete", "handleDuplicate", "handleExport"]) { | |
const original = SpriteSelectorItem.prototype[method]; | |
SpriteSelectorItem.prototype[method] = function (...args) { | |
if (typeof this.props.id === "number") { | |
const itemData = getItemData(this.props); | |
if (itemData) { | |
const originalProps = this.props; | |
this.props = { | |
...originalProps, | |
id: itemData.realIndex, | |
}; | |
const ret = original.call(this, ...args); | |
this.props = originalProps; | |
return ret; | |
} | |
} | |
return original.call(this, ...args); | |
}; | |
} | |
const originalHandleDragEnd = SpriteSelectorItem.prototype.handleDragEnd; | |
SpriteSelectorItem.prototype.handleDragEnd = function (...args) { | |
const itemData = getItemData(this.props); | |
if (itemData) { | |
if (typeof itemData.realIndex === "number" && this.props.dragging) { | |
// If the item is being dragged onto another group (eg. costume list -> sprite list) | |
// then we fake a drag event to make the `index` be the real index | |
const originalIndex = this.props.index; | |
const realIndex = itemData.realIndex; | |
if (originalIndex !== realIndex) { | |
const currentOffset = addon.tab.redux.state.scratchGui.assetDrag.currentOffset; | |
const sortableHOCInstance = getSortableHOCFromElement(this.ref); | |
if (currentOffset && sortableHOCInstance && sortableHOCInstance.getMouseOverIndex() === null) { | |
this.props.index = realIndex; | |
this.handleDrag(currentOffset); | |
this.props.index = originalIndex; | |
} | |
} | |
} | |
} | |
return originalHandleDragEnd.call(this, ...args); | |
}; | |
const originalHandleClick = SpriteSelectorItem.prototype.handleClick; | |
SpriteSelectorItem.prototype.handleClick = function (...args) { | |
const e = args[0]; | |
if (e && !this.noClick) { | |
const itemData = getItemData(this.props); | |
if (itemData) { | |
if (typeof itemData.folder === "string") { | |
e.preventDefault(); | |
setFolderOpen(this, itemData.folder, !isFolderOpen(this, itemData.folder)); | |
return; | |
} | |
if (typeof this.props.number === "number" && typeof itemData.realIndex === "number") { | |
e.preventDefault(); | |
if (this.props.onClick) { | |
this.props.onClick(itemData.realIndex); | |
} | |
return; | |
} | |
} | |
} | |
return originalHandleClick.call(this, ...args); | |
}; | |
const originalRender = SpriteSelectorItem.prototype.render; | |
SpriteSelectorItem.prototype.render = function () { | |
const itemData = getItemData(this.props); | |
if (itemData) { | |
const originalProps = this.props; | |
this.props = { | |
...this.props, | |
}; | |
if (typeof itemData.realName === "string") { | |
this.props.name = getNameWithoutFolder(itemData.realName); | |
} | |
if (typeof this.props.number === "number" && typeof itemData.realIndex === "number") { | |
// Convert 0-indexed to 1-indexed | |
this.props.number = itemData.realIndex + 1; | |
} | |
if (typeof itemData.folder === "string") { | |
this.props.name = itemData.folder; | |
if (itemData.folderOpen) { | |
this.props.details = msg("open-folder"); | |
} else { | |
this.props.details = msg("closed-folder"); | |
} | |
this.props.selected = false; | |
this.props.number = null; | |
this.props.className += ` ${getFolderColorClass(itemData.folder)} sa-folders-folder`; | |
} | |
if (typeof itemData.inFolder === "string") { | |
this.props.className += ` ${getFolderColorClass(itemData.inFolder)}`; | |
} | |
const result = originalRender.call(this); | |
this.props = originalProps; | |
return result; | |
} | |
return originalRender.call(this); | |
}; | |
}; | |
const patchVM = () => { | |
const RenderedTarget = vm.runtime.targets[0].constructor; | |
const originalInstallTargets = vm.installTargets; | |
vm.installTargets = function (...args) { | |
if (currentSpriteFolder !== null) { | |
const targets = args[0]; | |
const wholeProject = args[2]; | |
if (Array.isArray(targets) && !wholeProject) { | |
for (const target of targets) { | |
if (target.sprite) { | |
target.sprite.name = setFolderOfName(target.sprite.name, currentSpriteFolder); | |
} | |
} | |
} | |
} | |
return originalInstallTargets.call(this, ...args).then((r) => { | |
fixTargetOrder(); | |
return r; | |
}); | |
}; | |
const originalAddCostume = RenderedTarget.prototype.addCostume; | |
RenderedTarget.prototype.addCostume = function (...args) { | |
if (currentAssetFolder !== null) { | |
const costume = args[0]; | |
if (costume && typeof getFolderFromName(costume.name) !== "string") { | |
costume.name = setFolderOfName(costume.name, currentAssetFolder); | |
} | |
} | |
const r = originalAddCostume.call(this, ...args); | |
fixCostumeOrder(this); | |
return r; | |
}; | |
const originalAddSound = RenderedTarget.prototype.addSound; | |
RenderedTarget.prototype.addSound = function (...args) { | |
if (currentAssetFolder !== null) { | |
const sound = args[0]; | |
if (sound && typeof getFolderFromName(sound.name) !== "string") { | |
sound.name = setFolderOfName(sound.name, currentAssetFolder); | |
} | |
} | |
const r = originalAddSound.call(this, ...args); | |
fixSoundOrder(this); | |
return r; | |
}; | |
const abstractReorder = ( | |
{ guiItems, getAll, set, rename, getVMItemFromGUIItem, zeroIndexed, onFolderChanged }, | |
itemIndex, | |
newIndex | |
) => { | |
// First index depends on zeroIndexed | |
itemIndex = clamp(itemIndex, zeroIndexed ? 0 : 1, zeroIndexed ? guiItems.length - 1 : guiItems.length); | |
newIndex = clamp(newIndex, zeroIndexed ? 0 : 1, zeroIndexed ? guiItems.length - 1 : guiItems.length); | |
if (itemIndex === newIndex) { | |
return false; | |
} | |
let assets = getAll(); | |
const originalAssets = getAll(); | |
const targetItem = guiItems[itemIndex - (zeroIndexed ? 0 : 1)]; | |
const itemAtNewIndex = guiItems[newIndex - (zeroIndexed ? 0 : 1)]; | |
const targetItemData = getItemData(targetItem); | |
const itemAtNewIndexData = getItemData(itemAtNewIndex); | |
if (!targetItemData || !itemAtNewIndexData) { | |
console.warn("should never happen"); | |
return false; | |
} | |
const reorderingItems = typeof targetItemData.folder === "string" ? targetItem.items : [targetItem]; | |
const reorderingAssets = reorderingItems.map((i) => getVMItemFromGUIItem(i, assets)).filter((i) => i); | |
if (typeof itemAtNewIndexData.realIndex === "number") { | |
const newTarget = getVMItemFromGUIItem(itemAtNewIndex, assets); | |
if (!newTarget || reorderingAssets.includes(newTarget)) { | |
// Dragging folder into itself or target doesn't exist. Ignore. | |
return false; | |
} | |
} | |
let newFolder = null; | |
assets = assets.filter((i) => !reorderingAssets.includes(i)); | |
let realNewIndex; | |
if (newIndex === (zeroIndexed ? 0 : 1)) { | |
realNewIndex = zeroIndexed ? 0 : 1; | |
} else if (newIndex === guiItems.length - (zeroIndexed ? 1 : 0)) { | |
realNewIndex = assets.length; | |
} else if (typeof itemAtNewIndexData.realIndex === "number") { | |
newFolder = typeof itemAtNewIndexData.inFolder === "string" ? itemAtNewIndexData.inFolder : null; | |
let newAsset = getVMItemFromGUIItem(itemAtNewIndex, assets); | |
if (!newAsset) { | |
console.warn("should never happen"); | |
return false; | |
} | |
realNewIndex = assets.indexOf(newAsset); | |
if (newIndex > itemIndex) { | |
realNewIndex++; | |
} | |
} else if (typeof itemAtNewIndexData.folder === "string") { | |
let item; | |
let offset = 0; | |
if (newIndex < itemIndex) { | |
// A B [C D E] F G | |
// ^----------* | |
// A B C [D] E F G | |
// ^--------* | |
item = itemAtNewIndex.items[0]; | |
} else if (itemAtNewIndexData.folderOpen) { | |
// A B [C D E] F G | |
// *---^ | |
item = itemAtNewIndex.items[0]; | |
newFolder = itemAtNewIndexData.folder; | |
} else { | |
// A B [C] D E F G | |
// *----^ | |
item = itemAtNewIndex.items[itemAtNewIndex.items.length - 1]; | |
offset = 1; | |
} | |
let newAsset = getVMItemFromGUIItem(item, assets); | |
if (newAsset) { | |
realNewIndex = assets.indexOf(newAsset) + offset; | |
} else { | |
// Edge case: Dragging the first item of a list on top of the folder item | |
// A B [C D E] F G | |
// ^---* | |
newAsset = getVMItemFromGUIItem(item, originalAssets); | |
if (!newAsset) { | |
console.warn("should never happen"); | |
return false; | |
} | |
realNewIndex = originalAssets.indexOf(newAsset) + offset; | |
} | |
} else { | |
console.warn("should never happen"); | |
return false; | |
} | |
if (typeof targetItemData.folder === "string" && newFolder !== null) { | |
// Cannot drag a folder into another folder | |
return; | |
} | |
if (realNewIndex < (zeroIndexed ? 0 : 1) || realNewIndex > assets.length) { | |
console.warn("should never happen"); | |
return false; | |
} | |
assets.splice(realNewIndex, 0, ...reorderingAssets); | |
set(assets); | |
// If the folder has changed, update item names to match. | |
if (typeof targetItemData.folder !== "string" && targetItemData.inFolder !== newFolder) { | |
for (const asset of reorderingAssets) { | |
const name = asset.getName ? asset.getName() : asset.name; | |
rename(asset, setFolderOfName(name, newFolder)); | |
} | |
if (onFolderChanged) { | |
onFolderChanged(); | |
} | |
} | |
return true; | |
}; | |
vm.constructor.prototype.reorderTarget = function (targetIndex, newIndex) { | |
return abstractReorder( | |
{ | |
getAll: () => { | |
return this.runtime.targets; | |
}, | |
set: (targets) => { | |
this.runtime.targets = targets; | |
this.emitTargetsUpdate(); | |
}, | |
rename: (item, name) => { | |
this.renameSprite(item.id, ensureNotReserved(name)); | |
}, | |
getVMItemFromGUIItem: (item, targets) => { | |
return targets.find((i) => i.id === item.id); | |
}, | |
onFolderChanged: () => { | |
this.emitWorkspaceUpdate(); | |
}, | |
guiItems: currentSpriteItems, | |
zeroIndexed: false, | |
}, | |
targetIndex, | |
newIndex | |
); | |
}; | |
RenderedTarget.prototype.reorderCostume = function (costumeIndex, newIndex) { | |
return abstractReorder( | |
{ | |
getAll: () => { | |
return this.sprite.costumes; | |
}, | |
set: (assets) => { | |
this.sprite.costumes = assets; | |
}, | |
rename: (item, name) => { | |
this.renameCostume(this.sprite.costumes.indexOf(item), name); | |
}, | |
getVMItemFromGUIItem: (item, costumes) => { | |
const itemData = getItemData(item); | |
return costumes.find((c) => c.name === itemData.realName); | |
}, | |
guiItems: currentAssetItems, | |
zeroIndexed: true, | |
}, | |
costumeIndex, | |
newIndex | |
); | |
}; | |
RenderedTarget.prototype.reorderSound = function (soundIndex, newIndex) { | |
return abstractReorder( | |
{ | |
getAll: () => { | |
return this.sprite.sounds; | |
}, | |
set: (assets) => { | |
this.sprite.sounds = assets; | |
}, | |
rename: (item, name) => { | |
this.renameSound(this.sprite.sounds.indexOf(item), name); | |
}, | |
getVMItemFromGUIItem: (item, sounds) => { | |
const itemData = getItemData(item); | |
return sounds.find((c) => c.name === itemData.realName); | |
}, | |
guiItems: currentAssetItems, | |
zeroIndexed: true, | |
}, | |
soundIndex, | |
newIndex | |
); | |
}; | |
// Temporal bug fix for #5762 | |
const originalShareSoundToTarget = vm.shareSoundToTarget; | |
vm.shareSoundToTarget = function (...args) { | |
const target = this.runtime.getTargetById(args[1]); | |
if (!target) { | |
// Avoid reading property from null | |
return Promise.reject(new Error("Dropping sound into folder is not supported")); | |
// This would also work no matter what we returned, probably | |
// Original method returns a promise, so here too | |
} | |
return originalShareSoundToTarget.call(this, ...args); | |
}; | |
}; | |
const patchBackpack = (backpackInstance) => { | |
const Backpack = backpackInstance.constructor; | |
Backpack.prototype.sa_loadNextItem = function () { | |
if (!this.sa_queuedItems) return; | |
const item = this.sa_queuedItems.pop(); | |
if (item) { | |
let payload; | |
let type; | |
if (item.dragPayload) { | |
if (item.url) { | |
type = "SOUND"; | |
} else { | |
type = "COSTUME"; | |
} | |
payload = item.dragPayload; | |
} else if (item.id) { | |
type = "SPRITE"; | |
payload = item.id; | |
} | |
if (type && payload) { | |
originalHandleDrop.call(this, { | |
dragType: type, | |
payload: payload, | |
}); | |
} | |
} | |
}; | |
Backpack.prototype.componentDidUpdate = function (prevProps, prevState) { | |
if (!this.state.loading && prevState.loading && !this.state.error) { | |
this.sa_loadNextItem(); | |
} | |
}; | |
const originalHandleDrop = Backpack.prototype.handleDrop; | |
Backpack.prototype.handleDrop = function (...args) { | |
// When a folder is dropped into the backpack, upload all the items in the folder. | |
const dragInfo = args[0]; | |
const folderItems = dragInfo && dragInfo.payload && dragInfo.payload.sa_folder_items; | |
if (Array.isArray(folderItems)) { | |
addon.tab.confirm("", msg("confirm-backpack-folder"), { useEditorClasses: true }).then((result) => { | |
if (!result) return; | |
this.sa_queuedItems = folderItems; | |
this.sa_loadNextItem(); | |
}); | |
return; | |
} | |
return originalHandleDrop.call(this, ...args); | |
}; | |
backpackInstance.handleDrop = Backpack.prototype.handleDrop.bind(backpackInstance); | |
}; | |
// Backpack | |
{ | |
const clickListener = (e) => { | |
if (!e.target.closest('[class*="backpack_backpack-header_"]')) { | |
return; | |
} | |
setTimeout(() => { | |
const backpackContainer = document.querySelector("[class^='backpack_backpack-list_']"); | |
if (!backpackContainer) { | |
return; | |
} | |
document.removeEventListener("click", clickListener); | |
const backpackInstance = getBackpackFromElement(backpackContainer); | |
verifyBackpack(backpackInstance); | |
patchBackpack(backpackInstance); | |
}); | |
}; | |
document.addEventListener("click", clickListener, true); | |
} | |
// Sprite list | |
{ | |
const spriteSelectorItemElement = await addon.tab.waitForElement("[class^='sprite-selector_sprite-wrapper']", { | |
reduxCondition: (state) => !state.scratchGui.mode.isPlayerOnly, | |
}); | |
vm = addon.tab.traps.vm; | |
reactInternalKey = Object.keys(spriteSelectorItemElement).find((i) => i.startsWith(REACT_INTERNAL_PREFIX)); | |
const sortableHOCInstance = getSortableHOCFromElement(spriteSelectorItemElement); | |
const spriteSelectorItemInstance = spriteSelectorItemElement[reactInternalKey].child.child.child.stateNode; | |
verifySortableHOC(sortableHOCInstance); | |
verifySpriteSelectorItem(spriteSelectorItemInstance); | |
verifyVM(vm); | |
patchSortableHOC(sortableHOCInstance.constructor, TYPE_SPRITES); | |
patchSpriteSelectorItem(spriteSelectorItemInstance.constructor); | |
sortableHOCInstance.saInitialSetup(); | |
patchVM(); | |
} | |
// Costume and sound list | |
{ | |
const selectorListItem = await addon.tab.waitForElement("[class*='selector_list-item']", { | |
reduxCondition: (state) => state.scratchGui.editorTab.activeTabIndex !== 0 && !state.scratchGui.mode.isPlayerOnly, | |
}); | |
const sortableHOCInstance = getSortableHOCFromElement(selectorListItem); | |
verifySortableHOC(sortableHOCInstance); | |
patchSortableHOC(sortableHOCInstance.constructor, TYPE_ASSETS); | |
sortableHOCInstance.saInitialSetup(); | |
} | |
} | |