diff --git a/extensions/files.js b/extensions/files.js index f21616f395..eb64ade593 100644 --- a/extensions/files.js +++ b/extensions/files.js @@ -1,6 +1,10 @@ // Name: Files // ID: files -// Description: Read and download files. +// Description: Read, upload, and download files. +// By: GarboMuffin +// By: SharkPool +// By: Drago Cuven +// By: 0znzw // License: MIT AND MPL-2.0 (function (Scratch) { @@ -10,6 +14,22 @@ throw new Error("files extension must be run unsandboxed"); } + const menuIconURI = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMzMuMTY0IiBoZWlnaHQ9IjEzMy4xNjQiIHZpZXdCb3g9IjAgMCAxMzMuMTY0IDEzMy4xNjQiPjxnIHN0cm9rZS1taXRlcmxpbWl0PSIxMCI+PHBhdGggZD0iTTMgNjYuNTgyQzMgMzEuNDY3IDMxLjQ2NyAzIDY2LjU4MiAzczYzLjU4MiAyOC40NjcgNjMuNTgyIDYzLjU4Mi0yOC40NjcgNjMuNTgyLTYzLjU4MiA2My41ODJTMyAxMDEuNjk3IDMgNjYuNTgyeiIgZmlsbD0iI2ZjYjEwMyIgc3Ryb2tlPSIjYmY4YjExIiBzdHJva2Utd2lkdGg9IjYiLz48cGF0aCBkPSJNOTkuODkyIDQ5LjkyN3Y0OS45NjRjMCA0LjU4LTMuNzQ4IDguMzI4LTguMzI4IDguMzI4SDQxLjU1OGMtNC41OCAwLTguMjg1LTMuNzQ4LTguMjg1LTguMzI4bC4wNDEtNjYuNjE4YzAtNC41OCAzLjcwNi04LjMyOCA4LjI4Ni04LjMyOGgzMy4zMXoiIGZpbGw9Im5vbmUiIHN0cm9rZS1vcGFjaXR5PSIuMTQ5IiBzdHJva2U9IiMwMDAiIHN0cm9rZS13aWR0aD0iMTAiLz48cGF0aCBkPSJNOTkuODkyIDQ5LjkyN3Y0OS45NjRjMCA0LjU4LTMuNzQ4IDguMzI4LTguMzI4IDguMzI4SDQxLjU1OGMtNC41OCAwLTguMjg1LTMuNzQ4LTguMjg1LTguMzI4bC4wNDEtNjYuNjE4YzAtNC41OCAzLjcwNi04LjMyOCA4LjI4Ni04LjMyOGgzMy4zMXoiIGZpbGw9IiNmZmYiLz48cGF0aCBkPSJNNzAuNzIyIDU0LjExNVYzMS4xNjdsMjIuOTQ3IDIyLjk0OHoiIGZpbGw9IiNmY2IxMDMiLz48cGF0aCBkPSJNODQuNjY4IDY5LjkxNGMtLjAyLjA4OC0uMDM2LjE3NS0uMDYyLjI2Mi0uMDc3LjMyNC0uMjM2IDEuMDktLjM5NiAyLjU4N3EuMDAxLjAzOC0uMDA1LjA2MWMuODggMy41MzgtLjYwMiA1LjY5Mi0xLjU4NCA2LjY3NGEyIDIgMCAwIDEtLjIuMTljLS45NjIuODc1LTIuNjQzIDEuOTE4LTUuMTUyIDEuOTE4YTcuNjUgNy42NSAwIDAgMS0zLjQzNS0uNzg2Yy4wMjYgMS40MDMuMDQxIDMuMzM3LjA0MSA2LjAyYTYuOSA2LjkgMCAwIDEgMi40OTQgMS41MzggNy4xIDcuMSAwIDAgMSAyLjIxIDUuMTY3YzAgMi45MS0xLjY3NSA1LjQ0LTQuMzc1IDYuNTk3cS0uMTAzLjA0OC0uMjA1LjA4OGMtLjkwNS4zNDQtMS45MjMuNTE0LTMuMTE2LjUxNC0uNDMyIDAtLjg5NS0uMDItMS4zOTktLjA2N2ExOCAxOCAwIDAgMC0xLjI5NS4wMDVjLS45NzIuMTA4LTIuMzQuMjE2LTQuMTcuMzE5LS4wMzYgMC0uMDcyLjAwNS0uMTE0LjAwNXEtLjM2LjAxNS0uNjk5LjAxNWMtMy4yNzUgMC01LjY2Ni0xLjAzOC03LjExLTMuMDhxLS4wNDYtLjA2LS4wODMtLjEyM2MtMS40Ny0yLjE5NS0xLjUxMi00Ljk3Mi0uMTEzLTcuMjRxLjAxNy0uMDM1LjA0MS0uMDcxYy44OS0xLjM5OSAyLjE5LTIuMzk2IDMuODc3LTIuOTgzLjAyLS45OTIuMDE1LTIuMjM2LS4wMTYtMy43MTJhNy41IDcuNSAwIDAgMS0yLjg5LjU1NWMtMi44NjMgMC01LjIyOC0xLjcwMS02LjA1MS00LjI4OC0uMTctLjUyNC0uMzMtMS4yOS0uNy0zLjAxOGEyIDIgMCAwIDEtLjAzNS0uMTg1bC0uNzYxLTQuMjUyYTcgNyAwIDAgMC0uMTAzLS4zMjRjLS4zOTYtMS4xMjYtLjU4MS0yLjA4My0uNTgxLTMuMDA4IDAtMS4xNDcuMzM0LTIuODggMS45MTgtNC42MDcuNzk3LS44NzQgMi4yNDctMi4wMDUgNC41ODEtMi4zN3EuNDMzLS4wNzEuODc0LS4wNzJoNS42ODdxLjE5MiAwIC4zODUuMDE1YTcyIDcyIDAgMCAwIDUuNzgtLjAwNSA4MyA4MyAwIDAgMCA2LjkzLS41OTZjLjI1OC0uMDgzLjUzNi0uMTY1LjgzNC0uMjM3LjI4My0uMDcyLjU3LS4xMTguODU4LS4xNSAyLjE5LS4yIDQuMjMyLjQzMyA1LjgyIDEuNzkgMS45NyAxLjY5MiAyLjg0IDQuMjUyIDIuMzIgNi44NTQiIGZpbGw9IiNiZjhiMTEiLz48cGF0aCBkPSJNNzkuMTIxIDY4LjgwNnEtLjMxNiAxLjI2Ni0uNTQzIDMuNDM3LS4wOSAxLjA4Ni4wOSAxLjc2NC4zMTYgMS4xMzEtLjA0NCAxLjQ5My0uNDk5LjQ1MS0xLjM1Ny40NTItLjk5NiAwLTEuMzEyLS41OTJBNDEgNDEgMCAwIDEgNzYgNzEuOTQzcS4wOS0xLjk2LjA5LTIuMDUtLjA0NSAwLS4xMzUtLjA5Mi00LjIwNy4xODMtNy45MTUuNTQ1LS4wOS40NTMgMCAxLjQ1LjE4IDEuNDA0LjE4IDEuNjMtLjE4IDEuNDA2LS4xOCA0LjE3LjE4IDEuMTc5LjE4IDEwLjEwNnYzLjMwOXEwIC43Ny4yMjUgMS4wODdoMi43NzdxLjc2Mi0uMDkgMS4yMzIuMzYyLjQ3LjQ1My40NyAxLjA4NSAwIC45OTQtLjk1IDEuNDAzLS41ODguMjI1LTIuMDguMDktLjcyMy0uMDQ1LTIuMTI2IDAtMS4yNjUuMTU3LTQuMDcuMzE2LTIuNDQzLjA5LTIuOTg1LS42NzgtLjM2My0uNTQyIDAtMS4xMy42MzItLjk5NSAzLjMwMi0uOTk1Ljk0OCAwIDEuMTc1LS4xNTguMjI2LS4xNi4yMjYtLjYxVjg5LjAzcS4xMzUtMi4zMDIgMC02Ljg2MS0uMTgtNi41NDQuMTgxLTExLjUwOWwtLjEzNy0uMTM2cS0xLjYzNy4wOS01LjI3NS0uMDktLjQxIDAtMi43NzQuMTgxLjU4NyA0LjExNy43MjMgNi4xOTYgMCAuMjcyLS4wOSAxLjIyMS0uMDQ2LjY3OC0xLjEzMS42NzktLjU4OSAwLS42NzgtLjQwNC0uMDQ2LS4wOS0uNTQzLTIuNDIybC0uNzY5LTQuMzA2cTAtLjE3OS0uMzE2LTEuMTIxLS4yNy0uNzY0LS4yNy0xLjE2Ni0uMDAxLS4zMTUuNDMtLjc4NS40My0uNDcxIDEuMjg5LS42MDZoNS42MDhxMy42NjMuMTM1IDYuNTEyIDBhODggODggMCAwIDAgOC4wNS0uNzI0cS4yNzMtLjEzNS44MTQtLjI3Ljk5NS0uMDkgMS42MjkuNDUyLjYzMy41NC40NTIgMS40NDciIGZpbGw9IiNmZmYiLz48L2c+PC9zdmc+"; + + const vm = Scratch.vm; + const runtime = vm.runtime; + + const builtInFonts = [ + "Sans Serif", + "Serif", + "Handwriting", + "Marker", + "Curly", + "Pixel", + "Scratch", + "inherit", + ]; const MODE_MODAL = "modal"; const MODE_IMMEDIATELY_SHOW_SELECTOR = "selector"; const MODE_ONLY_SELECTOR = "only-selector"; @@ -18,10 +38,35 @@ MODE_IMMEDIATELY_SHOW_SELECTOR, MODE_ONLY_SELECTOR, ]; - let openFileSelectorMode = MODE_MODAL; + const AS_TEXT = "text", + AS_DATA_URL = "url", + AS_HEX = "hex"; + const AS_BASE64 = "base64", + AS_BUFFER = "arrayBuffer"; - const AS_TEXT = "text"; - const AS_DATA_URL = "url"; + let enableVis = true; + let openFileSelectorMode = MODE_MODAL; + let storedFiles = {}; + let FileName = "", + FileSize = "0kb", + RawFileSize = "0", + fileDate = "", + lastData = ""; + let openModals = 0; + let selectorOptions = { + borderColor: "#888", + textColor: "#000", + outer: "#fff", + sizeFont: 1.5, + borderRadius: 16, + borderType: "dashed", + font: "inherit", + shadow: 0.5, + image: "", + textV: "", + fontWeight: 40, + letterSpacing: "normal", + }; /** * @param {HTMLInputElement} input @@ -40,9 +85,10 @@ /** * @param {string} accept See MODE_ constants above * @param {string} as See AS_ constants above + * @param {boolean} override makes modal use the File API if true * @returns {Promise} format given by as parameter */ - const showFilePrompt = (accept, as) => + const showFilePrompt = (accept, as, override) => new Promise((_resolve) => { // We can't reliably show an picker without "user interaction" in all environments, // so we have to show our own UI anyways. We may as well use this to implement some nice features @@ -54,14 +100,25 @@ /** @param {string} text */ const callback = (text) => { - _resolve(text); - Scratch.vm.renderer.removeOverlay(outer); - Scratch.vm.runtime.off("PROJECT_STOP_ALL", handleProjectStopped); + let cleansedTxt = text; + if (override === undefined) { + if ([AS_HEX, AS_BASE64].includes(as)) { + let uri = cleansedTxt.split(","); + cleansedTxt = uri.splice(1, uri.length).join(","); + if (as === AS_HEX) cleansedTxt = base64ToHex(cleansedTxt, " "); + } + lastData = cleansedTxt; + } + openModals--; + _resolve(cleansedTxt); + vm.renderer.removeOverlay(outer); + runtime.off("PROJECT_STOP_ALL", handleProjectStopped); document.body.removeEventListener("keydown", handleKeyDown, { capture: true, }); }; + openModals++; let isReadingFile = false; /** @param {File} file */ @@ -73,17 +130,21 @@ const reader = new FileReader(); reader.onload = () => { + FileName = file.name; + FileSize = formatFileSize(file.size); + RawFileSize = file.size; + const rawDate = new Date(file.lastModified); + fileDate = rawDate.toLocaleString(); callback(/** @type {string} */ (reader.result)); }; reader.onerror = () => { console.error("Failed to read file as text", reader.error); callback(""); }; - if (as === AS_TEXT) { - reader.readAsText(file); - } else { - reader.readAsDataURL(file); - } + if (override !== undefined) callback(file); + else if (as === AS_TEXT) reader.readAsText(file); + else if (as === AS_BUFFER) reader.readAsArrayBuffer(file); + else reader.readAsDataURL(file); }; /** @param {KeyboardEvent} e */ @@ -101,9 +162,34 @@ const handleProjectStopped = () => { callback(""); }; - Scratch.vm.runtime.on("PROJECT_STOP_ALL", handleProjectStopped); + runtime.on("PROJECT_STOP_ALL", handleProjectStopped); + + const handleOverride = async () => { + let fileInfo; + if (override === undefined) { + // execute normal behaviour + input.click(); + } else { + try { + if (override === "folder") { + fileInfo = await window.showDirectoryPicker({ + multiple: false, + types: [{ accept: { "*/*": [] } }], + }); + } else { + fileInfo = await window.showOpenFilePicker({ + multiple: false, + types: [{ accept: { "*/*": accept } }], + }); + } + callback(fileInfo); + } catch { + callback(""); + } + } + }; - const INITIAL_BORDER_COLOR = "#888"; + const INITIAL_BORDER_COLOR = selectorOptions.borderColor; const DROPPING_BORDER_COLOR = "#03a9fc"; const outer = document.createElement("div"); @@ -113,26 +199,27 @@ outer.style.display = "flex"; outer.style.alignItems = "center"; outer.style.justifyContent = "center"; - outer.style.background = "rgba(0, 0, 0, 0.5)"; - outer.style.color = "black"; outer.style.colorScheme = "light"; - outer.addEventListener("dragover", (e) => { - if (e.dataTransfer.types.includes("Files")) { - e.preventDefault(); - e.dataTransfer.dropEffect = "copy"; - modal.style.borderColor = DROPPING_BORDER_COLOR; - } - }); - outer.addEventListener("dragleave", () => { - modal.style.borderColor = INITIAL_BORDER_COLOR; - }); - outer.addEventListener("drop", (e) => { - const file = e.dataTransfer.files[0]; - if (file) { - e.preventDefault(); - readFile(file); - } - }); + if (override === undefined) { + // the File API cant exactly support dragging files + outer.addEventListener("dragover", (e) => { + if (e.dataTransfer.types.includes("Files")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + modal.style.borderColor = DROPPING_BORDER_COLOR; + } + }); + outer.addEventListener("dragleave", () => { + modal.style.borderColor = INITIAL_BORDER_COLOR; + }); + outer.addEventListener("drop", (e) => { + const file = e.dataTransfer.files[0]; + if (file) { + e.preventDefault(); + readFile(file); + } + }); + } outer.addEventListener("click", (e) => { if (e.target === outer) { callback(""); @@ -140,17 +227,17 @@ }); const modal = document.createElement("button"); + modal.id = "tw-files-modal"; modal.style.boxShadow = "0 0 10px -5px currentColor"; modal.style.cursor = "pointer"; - modal.style.font = "inherit"; - modal.style.background = "white"; + modal.style.backgroundSize = "cover"; modal.style.padding = "16px"; - modal.style.borderRadius = "16px"; modal.style.border = `8px dashed ${INITIAL_BORDER_COLOR}`; modal.style.position = "relative"; modal.style.textAlign = "center"; - modal.addEventListener("click", () => { - input.click(); + modal.addEventListener("click", async () => { + // will execute "input.click()" if override is "undefined" + await handleOverride(); }); modal.focus(); outer.appendChild(modal); @@ -158,17 +245,21 @@ const input = document.createElement("input"); input.type = "file"; input.accept = accept; - input.addEventListener("change", (e) => { - // @ts-expect-error - const file = e.target.files[0]; - if (file) { - readFile(file); - } - }); + if (override === undefined) { + input.addEventListener("change", (e) => { + // @ts-expect-error + const file = e.target.files[0]; + if (file) { + readFile(file); + } + }); + } const title = document.createElement("div"); - title.textContent = Scratch.translate("Select or drop file"); - title.style.fontSize = "1.5em"; + title.textContent = + override === undefined + ? Scratch.translate("Select or drop file") + : Scratch.translate("Select file"); title.style.marginBottom = "8px"; modal.appendChild(title); @@ -196,7 +287,7 @@ } if (openFileSelectorMode !== MODE_ONLY_SELECTOR) { - const overlay = Scratch.vm.renderer.addOverlay(outer, "scale"); + const overlay = vm.renderer.addOverlay(outer, "scale"); overlay.container.style.zIndex = "100"; } @@ -204,7 +295,8 @@ openFileSelectorMode === MODE_IMMEDIATELY_SHOW_SELECTOR || openFileSelectorMode === MODE_ONLY_SELECTOR ) { - input.click(); + // will run "input.click()" if override is "undefined" + handleOverride(); } if (openFileSelectorMode === MODE_ONLY_SELECTOR) { @@ -213,7 +305,39 @@ callback(""); }); } + + updateModalVisuals(); + }); + + const updateModalVisuals = () => { + const allModals = document.querySelectorAll(`button[id="tw-files-modal"]`); + allModals.forEach((modal) => { + modal.parentNode.style.background = `rgba(0, 0, 0, ${selectorOptions.shadow})`; + modal.parentNode.style.color = selectorOptions.textColor; + + modal.style.font = selectorOptions.font; + modal.style.fontFamily = selectorOptions.font; + modal.style.background = selectorOptions.image + ? selectorOptions.image + : selectorOptions.outer; + modal.style.borderRadius = `${selectorOptions.borderRadius}px`; + modal.style.borderStyle = selectorOptions.borderType; + modal.style.borderColor = selectorOptions.borderColor; + + const title = modal.children[0]; + if (selectorOptions.textV) title.textContent = selectorOptions.textV; + title.style.color = selectorOptions.textColor; + title.style.fontSize = `${selectorOptions.sizeFont}em`; + title.style.fontWeight = selectorOptions.fontWeight * 9; + title.style.letterSpacing = `${selectorOptions.letterSpacing}px`; + + const subtitle = modal.children[1]; + subtitle.style.color = selectorOptions.textColor; + subtitle.style.fontSize = `${selectorOptions.sizeFont - 0.5}em`; + subtitle.style.fontWeight = selectorOptions.fontWeight * 9; + subtitle.style.letterSpacing = `${selectorOptions.letterSpacing}px`; }); + }; /** * @param {Blob} blob Data to download @@ -259,15 +383,66 @@ await downloadBlob(blob, file); }; + /** + * @param {number} size + * @returns {string} formatted byte size + */ + const formatFileSize = (size) => { + const units = ["B", "KB", "MB", "GB", "TB"]; + let i = 0; + while (size >= 1024 && i < units.length - 1) { + size /= 1024; + i++; + } + return `${size.toFixed(2)} ${units[i]}`; + }; + + /** + * @param {string} str + * @param {string} delim + * @returns {string} hex split by delimiter + */ + function base64ToHex(str, delim) { + const raw = atob(str); + let result = ""; + for (let i = 0; i < raw.length; i++) { + const hex = raw.charCodeAt(i).toString(16); + result += delim.toString() + (hex.length === 2 ? hex : "0" + hex); + } + return result.toUpperCase(); + } + + /** + * @param {Uint8Array} buffer + * @returns {string} base64 + */ + function bufferToBase64(buffer) { + var binary = ""; + var bytes = new Uint8Array(buffer); + var len = bytes.byteLength; + for (var i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } + class Files { + constructor() { + this._showUnsafeOptions = false; + } getInfo() { return { id: "files", name: Scratch.translate("Files"), + menuIconURI, color1: "#fcb103", color2: "#db9a37", color3: "#db8937", blocks: [ + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Uploading"), + }, { opcode: "showPicker", blockType: Scratch.BlockType.REPORTER, @@ -279,15 +454,14 @@ opcode: "showPickerExtensions", blockType: Scratch.BlockType.REPORTER, text: Scratch.translate("open a [extension] file"), + hideFromPalette: true, arguments: { extension: { type: Scratch.ArgumentType.STRING, defaultValue: ".txt", }, }, - hideFromPalette: true, }, - { opcode: "showPickerAs", blockType: Scratch.BlockType.REPORTER, @@ -314,9 +488,10 @@ }, }, }, - - "---", - + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Downloading"), + }, { opcode: "download", blockType: Scratch.BlockType.COMMAND, @@ -347,9 +522,10 @@ }, }, }, - - "---", - + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Extra"), + }, { opcode: "setOpenMode", blockType: Scratch.BlockType.COMMAND, @@ -362,20 +538,257 @@ }, }, }, + { + opcode: "fileInfo", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("last opened file [FORMAT]"), + arguments: { + FORMAT: { + type: Scratch.ArgumentType.STRING, + menu: "FILE_INFO", + }, + }, + }, + "---", + { + opcode: "modalOpen", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("is modal open?"), + }, + { + opcode: "findFileSize", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("[TYPE] file size of [FILE]"), + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "FILE_SIZES", + }, + FILE: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("Hello, world!"), + }, + }, + }, + { blockType: Scratch.BlockType.LABEL, text: "Stored Files" }, + { + opcode: "checkFileAPI", + blockType: Scratch.BlockType.BOOLEAN, + text: Scratch.translate("is file writing supported?"), + }, + { + opcode: "allStored", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("all stored files"), + }, + { + opcode: "setStoredFile", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate( + "open new stored [FILE] file named [NAME] as [TYPE]" + ), + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("my-file-1"), + }, + FILE: { + type: Scratch.ArgumentType.STRING, + defaultValue: ".txt", + }, + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "encoding", + }, + }, + }, + { + opcode: "storedFolder", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate( + "open folder and store files as [TYPE] with name [NAME]" + ), + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "encoding", + }, + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("my-folder-1"), + }, + }, + }, + { + opcode: "deleteStoredFile", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("delete file [NAME] from [OPTION]"), + arguments: { + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("my-file-1"), + }, + OPTION: { + type: Scratch.ArgumentType.STRING, + menu: "DELETION", + }, + }, + }, + "---", + { + opcode: "updateFile", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("write [TXT] to stored file [NAME]"), + arguments: { + TXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("new content"), + }, + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("my-file-1"), + }, + }, + }, + { + opcode: "storedInfo", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("[FORMAT] in stored file [NAME]"), + arguments: { + FORMAT: { + type: Scratch.ArgumentType.STRING, + menu: "FILE_INFO", + }, + NAME: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("my-file-1"), + }, + }, + }, + "---", + { + opcode: "moveStorage", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("migrate files to CST's ZIP Extension"), + }, + { + blockType: Scratch.BlockType.LABEL, + text: Scratch.translate("Visuals"), + }, + { + func: "toggleVis", + blockType: Scratch.BlockType.BUTTON, + text: Scratch.translate( + `${enableVis ? "En" : "Dis"}able Customization` + ), + }, + { + opcode: "resetStyle", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("reset selector style to default"), + hideFromPalette: enableVis, + }, + "---", + { + opcode: "borderColors", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set selector [OPTION] color to [COLOR]"), + hideFromPalette: enableVis, + arguments: { + OPTION: { + type: Scratch.ArgumentType.STRING, + menu: "visualColors", + }, + COLOR: { + type: Scratch.ArgumentType.COLOR, + }, + }, + }, + { + opcode: "visualsSelect", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set selector [OPTION] to [AMT]"), + hideFromPalette: enableVis, + arguments: { + OPTION: { + type: Scratch.ArgumentType.STRING, + menu: "visualOptions", + }, + AMT: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 15, + }, + }, + }, + { + opcode: "imageSet", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set selector image to [IMG]"), + hideFromPalette: enableVis, + arguments: { + IMG: { + type: Scratch.ArgumentType.STRING, + defaultValue: "https://extensions.turbowarp.org/dango.png", + }, + }, + }, + { + opcode: "borderTypeSet", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set selector border type to [TYPE]"), + hideFromPalette: enableVis, + arguments: { + TYPE: { + type: Scratch.ArgumentType.STRING, + menu: "borderTypes", + }, + }, + }, + { + opcode: "fontSet", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set selector font to [FONT]"), + hideFromPalette: enableVis, + arguments: { + FONT: { + type: Scratch.ArgumentType.STRING, + menu: "font", + }, + }, + }, + { + opcode: "textSet", + blockType: Scratch.BlockType.COMMAND, + text: Scratch.translate("set file selector text to [TEXT]"), + hideFromPalette: enableVis, + arguments: { + TEXT: { + type: Scratch.ArgumentType.STRING, + defaultValue: Scratch.translate("Insert File Here"), + }, + }, + }, + { + opcode: "currentX", + blockType: Scratch.BlockType.REPORTER, + text: Scratch.translate("current selector [THING]"), + hideFromPalette: enableVis, + arguments: { + THING: { + type: Scratch.ArgumentType.STRING, + menu: "modalVisuals", + }, + }, + }, ], menus: { + font: { + acceptReporters: true, + items: "getFonts", + }, encoding: { acceptReporters: true, - items: [ - { - text: Scratch.translate("text"), - value: AS_TEXT, - }, - { - text: "data: URL", - value: AS_DATA_URL, - }, - ], + items: "getEncodings", }, automaticallyOpen: { acceptReporters: true, @@ -395,10 +808,252 @@ }, ], }, + modalVisuals: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("border color"), + value: "border", + }, + { + text: Scratch.translate("text color"), + value: "text", + }, + { + text: Scratch.translate("background color"), + value: "outer", + }, + { + text: Scratch.translate("overlay opacity"), + value: "shadow", + }, + { + text: Scratch.translate("font"), + value: "font", + }, + { + text: Scratch.translate("font size"), + value: "sizeFont", + }, + { + text: Scratch.translate("font thickness"), + value: "fontWeight", + }, + { + text: Scratch.translate("letter spacing"), + value: "letterSpacing", + }, + { + text: Scratch.translate("border radius"), + value: "borderRadius", + }, + { + text: Scratch.translate("border type"), + value: "borderType", + }, + { + text: Scratch.translate("background image"), + value: "image", + }, + { + text: Scratch.translate("text"), + value: "textV", + }, + ], + }, + FILE_SIZES: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("formatted"), + value: "formatted", + }, + { + text: Scratch.translate("unformatted"), + value: "unformatted", + }, + ], + }, + FILE_INFO: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("data"), + value: "data", + }, + { + text: Scratch.translate("name"), + value: "name", + }, + { + text: Scratch.translate("modified date"), + value: "modified date", + }, + { + text: Scratch.translate("size formatted"), + value: "size formatted", + }, + { + text: Scratch.translate("size unformatted"), + value: "size unformatted", + }, + ], + }, + DELETION: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("storage"), + value: "storage", + }, + { + text: Scratch.translate("this device"), + value: "this device", + }, + { + text: Scratch.translate("both"), + value: "both", + }, + ], + }, + visualColors: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("border"), + value: "border", + }, + { + text: Scratch.translate("text"), + value: "text", + }, + { + text: Scratch.translate("background"), + value: "background", + }, + ], + }, + visualOptions: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("font size"), + value: "font size", + }, + { + text: Scratch.translate("font thickness"), + value: "font thickness", + }, + { + text: Scratch.translate("letter spacing"), + value: "letter spacing", + }, + { + text: Scratch.translate("border radius"), + value: "border radius", + }, + { + text: Scratch.translate("overlay opacity"), + value: "overlay opacity", + }, + ], + }, + borderTypes: { + acceptReporters: true, + items: [ + { + text: Scratch.translate("dotted"), + value: "dotted", + }, + { + text: Scratch.translate("dashed"), + value: "dashed", + }, + { + text: Scratch.translate("solid"), + value: "solid", + }, + { + text: Scratch.translate("double"), + value: "double", + }, + { + text: Scratch.translate("groove"), + value: "groove", + }, + { + text: Scratch.translate("ridge"), + value: "ridge", + }, + { + text: Scratch.translate("inset"), + value: "inset", + }, + { + text: Scratch.translate("outset"), + value: "outset", + }, + { + text: Scratch.translate("none"), + value: "none", + }, + ], + }, }, }; } + // Helper Funcs + getFonts() { + const customFonts = runtime.fontManager + ? runtime.fontManager + .getFonts() + .map((i) => ({ text: i.name, value: i.family })) + : []; + return [...builtInFonts, ...customFonts]; + } + + getEncodings(onlySafe) { + const types = [ + { text: Scratch.translate("text"), value: AS_TEXT }, + { text: "data: URL", value: AS_DATA_URL }, + { text: "base64", value: AS_BASE64 }, + { text: "hex", value: AS_HEX }, + ]; + if (this._showUnsafeOptions) + types.push({ text: "arrayBuffer", value: AS_BUFFER }); + return types; + } + + toggleVis() { + (enableVis = enableVis ? false : true), + vm.extensionManager.refreshBlocks(); + } + + updateStore(name, data, metaData) { + const rawDate = new Date(metaData.lastModified); + storedFiles[name].data = { + size: metaData.size, + sizeFormat: formatFileSize(metaData.size), + dateFormat: rawDate.toLocaleString(), + data, + }; + } + + async encodeData(meta, format) { + const text = await meta.text(); + const buffer = await meta.arrayBuffer(); + if (format === AS_TEXT) return text; + else if (format === AS_BUFFER) return buffer; + const base64 = bufferToBase64(buffer); + if (format === AS_BASE64) return base64; + else if (format === AS_DATA_URL) + return `data:${meta.type};charset=utf-8;base64,${base64}`; + else if (format === AS_HEX) return base64ToHex(base64, " "); + return text; + } + + // Block Funcs (Upload & Download) showPicker() { return showFilePrompt("", AS_TEXT); } @@ -444,7 +1099,213 @@ console.warn(`unknown mode`, args.mode); } } + + fileInfo(args) { + if (args.FORMAT === "size formatted") return FileSize; + else if (args.FORMAT === "size unformatted") return RawFileSize; + else if (args.FORMAT === "modified date") return fileDate; + else if (args.FORMAT === "data") return lastData; + return FileName; + } + + modalOpen() { + return openModals !== 0; + } + + // File Writing & Folders + checkFileAPI() { + return "showOpenFilePicker" in window; + } + + allStored() { + return JSON.stringify(Object.keys(storedFiles)); + } + + async setStoredFile(args) { + if (!this.checkFileAPI()) return; + let fileTypes = args.FILE ? args.FILE.split(" ") : []; + try { + const picker = await showFilePrompt(fileTypes, "", "window"); + if (!picker) return; + storedFiles[args.NAME] = { file: picker[0], data: {} }; + const metaData = await picker[0].getFile(); + const encodedData = await this.encodeData(metaData, args.TYPE); + this.updateStore(args.NAME, encodedData, metaData); + } catch (e) { + console.warn(e); + } + } + + async storedFolder(args) { + if (!this.checkFileAPI()) return; + try { + const picker = await showFilePrompt("Folder", "", "folder"); + if (!picker) return; + const entries = picker.entries(); + const folderN = args.NAME ? args.NAME : picker.name; + let thisFile = ""; + while (thisFile !== undefined) { + const outerData = await entries.next(); + thisFile = outerData.value; + if (thisFile !== undefined) { + const innerData = thisFile[1]; + const name = `${folderN}/${innerData.name}`; + storedFiles[name] = { file: innerData, data: {} }; + const metaData = await innerData.getFile(); + const encodedData = await this.encodeData(metaData, args.TYPE); + this.updateStore(name, encodedData, metaData); + } + } + } catch (e) { + console.warn(e); + } + } + + deleteStoredFile(args) { + if (args.OPTION === "this device" || args.OPTION === "both") { + if (!this.checkFileAPI() || storedFiles[args.NAME] === undefined) + return; + storedFiles[args.NAME].file.remove(); + } + if (args.OPTION === "storage" || args.OPTION === "both") + delete storedFiles[args.NAME]; + } + + async updateFile(args) { + if (!this.checkFileAPI() || storedFiles[args.NAME] === undefined) return; + try { + const writable = await storedFiles[args.NAME].file.createWritable(); + await writable.write(args.TXT); + await writable.close(); + this.updateStore(args.NAME, args.TXT, { + lastModified: Date.now(), + size: args.TXT.length, + }); + } catch (e) { + console.warn(e); + } + } + + storedInfo(args) { + const fileInfo = storedFiles[args.NAME]; + if (fileInfo === undefined) return ""; + else if (args.FORMAT === "size formatted") + return fileInfo.data.sizeFormat; + else if (args.FORMAT === "size unformatted") return fileInfo.data.size; + else if (args.FORMAT === "modified date") return fileInfo.data.dateFormat; + else if (args.FORMAT === "data") return fileInfo.data.data; + return fileInfo.file.name; + } + + moveStorage() { + const ext = runtime.ext_cst1229zip; + if (ext === undefined) return; + ext.createEmptyAs({ NAME: "filesExpanded_storedFiles" }); + if (ext.zipError) return; + const zip = ext.zips["filesExpanded_storedFiles"]; + for (const [name, file] of Object.entries(storedFiles)) { + zip.file(name, file.data.data); + } + } + + // Extra + findFileSize(args) { + const size = Scratch.Cast.toString(args.FILE).length; // bytes + return args.TYPE === "formatted" ? formatFileSize(size) : size; + } + + // Visuals + resetStyle() { + selectorOptions = { + borderColor: "#888", + textColor: "#000", + outer: "#fff", + sizeFont: 1.5, + borderRadius: 16, + borderType: "dashed", + font: "inherit", + shadow: 0.5, + image: "", + textV: "", + fontWeight: 40, + letterSpacing: "normal", + }; + updateModalVisuals(); + } + + borderColors(args) { + switch (args.OPTION) { + case "text": + selectorOptions.textColor = args.COLOR; + break; + case "background": + selectorOptions.outer = args.COLOR; + selectorOptions.image = ""; + break; + default: + selectorOptions.borderColor = args.COLOR; + } + updateModalVisuals(); + } + + visualsSelect(args) { + const amtIn = Scratch.Cast.toNumber(args.AMT); + switch (args.OPTION) { + case "font size": + selectorOptions.sizeFont = amtIn / 10; + break; + case "font thickness": + selectorOptions.fontWeight = amtIn; + break; + case "letter spacing": + selectorOptions.letterSpacing = amtIn; + break; + case "border radius": + selectorOptions.borderRadius = amtIn; + break; + case "overlay opacity": + selectorOptions.shadow = Scratch.Cast.toNumber(amtIn) / 100; + break; + default: + selectorOptions.border = amtIn; + } + updateModalVisuals(); + } + + borderTypeSet(args) { + selectorOptions.borderType = args.TYPE; + updateModalVisuals(); + } + + fontSet(args) { + selectorOptions.font = args.FONT; + updateModalVisuals(); + } + + currentX(args) { + if (args.THING === "shadow" || args.THING === "sizeFont") { + const multiplier = args.THING === "shadow" ? 100 : 10; + return selectorOptions[args.THING] * multiplier; + } + return selectorOptions[args.THING]; + } + + imageSet(args) { + Scratch.canFetch(encodeURI(args.IMG)).then((canFetch) => { + if (canFetch) { + selectorOptions.image = `url(${encodeURI(args.IMG)})`; + updateModalVisuals(); + } else { + console.warn("Cannot fetch content from the URL."); + } + }); + } + + textSet(args) { + selectorOptions.textV = args.TEXT; + updateModalVisuals(); + } } - Scratch.extensions.register(new Files()); + Scratch.extensions.register((runtime.ext_Files = new Files())); })(Scratch);