From 055e6b7c5725856bbae6e05d5a2f6286094a529b Mon Sep 17 00:00:00 2001 From: rgantzos <86856959+rgantzos@users.noreply.github.com> Date: Sun, 30 Mar 2025 18:07:38 -0700 Subject: [PATCH] Copy and Paste Lists --- api/feature.js | 3 + features/copy-paste-lists/data.json | 15 +++ features/copy-paste-lists/script.js | 156 ++++++++++++++++++++++++++++ features/features.json | 5 + 4 files changed, 179 insertions(+) create mode 100644 features/copy-paste-lists/data.json create mode 100644 features/copy-paste-lists/script.js diff --git a/api/feature.js b/api/feature.js index 8eb4b689..b3c72521 100644 --- a/api/feature.js +++ b/api/feature.js @@ -90,6 +90,9 @@ class Feature { return element[reactKey] } + this.getInternalKey = function(element) { + return Object.keys(element).find((key) => key.startsWith("__reactInternalInstance")) || null + } this.redux = document.querySelector("#app")?.[ Object.keys(app).find((key) => key.startsWith("__reactContainer")) ].child.stateNode.store diff --git a/features/copy-paste-lists/data.json b/features/copy-paste-lists/data.json new file mode 100644 index 00000000..797ec086 --- /dev/null +++ b/features/copy-paste-lists/data.json @@ -0,0 +1,15 @@ +{ + "title": "Copy and Paste Lists", + "description": "Allows you to right-click on lists on the stage to copy and paste large amounts of items.", + "credits": [ + { + "username": "Brass_Glass", + "url": "https://scratch.mit.edu/users/Brass_Glass/" + }, + { "username": "rgantzos", "url": "https://scratch.mit.edu/users/rgantzos/" } + ], + "type": ["Editor"], + "tags": ["New", "Featured"], + "dynamic": true, + "scripts": [{ "file": "script.js", "runOn": "/projects/*" }] +} diff --git a/features/copy-paste-lists/script.js b/features/copy-paste-lists/script.js new file mode 100644 index 00000000..e1b21a85 --- /dev/null +++ b/features/copy-paste-lists/script.js @@ -0,0 +1,156 @@ +export default async function ({ feature, console, scratchClass }) { + document.body.addEventListener("contextmenu", async (event) => { + const ctxTarget = event.target.closest( + ".react-contextmenu-wrapper, [data-state]" + ); + if (!ctxTarget) return; + const ctx = feature.getInternals(ctxTarget); + + if (!ctx) return; + + const listCtx = findParentWithProp(ctx, "opcode"); + if (listCtx && listCtx.props.id) { + let listId = listCtx.props.id; + + let stage = feature.traps.vm.runtime.getTargetForStage(); + let list = stage.lookupVariableById(listId); + + if (list.type !== "list") return; + + const menuInternal = + feature.getInternals(ctxTarget).return.stateNode.props.id; + if (!menuInternal) return; + + let menus = document.querySelectorAll("body > nav.react-contextmenu"); + + let menu = Array.prototype.find.call(menus, (pMenu) => { + const menuInternals = feature.getInternals(pMenu); + return menuInternals?.return?.stateNode?.props?.id === menuInternal; + }); + + if (menu.querySelector(".ste-copy-paste-list")) return; + + menu.prepend( + optionBuilder("paste", async function () { + try { + let text = await getClipboardWithContextMenu(); + if (text) { + let newItems = text.split("\n"); + + if ( + confirm( + `Are you sure you want to add ${newItems.length} item${ + newItems.length === 1 ? "s" : "" + } to your "${ + stage.lookupVariableById(listId).name + }" list? This will clear all existing items.` + ) + ) { + stage.lookupVariableById(listId).value = newItems || []; + + updateList(listId); + + alert( + `Successfully pasted ${newItems.length} items to your "${ + stage.lookupVariableById(listId).name + }" list!` + ); + } + } else { + alert("Oops! You don't have anything copied!"); + } + } catch (err) { + alert("Oops! Something went wrong."); + } + closeContextMenu(); + }) + ); + + menu.prepend( + optionBuilder("copy", async function () { + let text = stage.lookupVariableById(listId).value.join("\n"); + await navigator.clipboard.writeText(text); + closeContextMenu(); + }) + ); + + window.menu = menu; + } + + function updateList(id) { + feature.traps.vm.runtime.requestUpdateMonitor( + new Map([ + ["id", id], + ["x", Date.now()], + ["y", 0], + ]) + ); + } + + function closeContextMenu() { + const clickEvent = new MouseEvent("mousedown", { + bubbles: true, + cancelable: true, + view: window, + }); + + document.body.dispatchEvent(clickEvent); + } + + function optionBuilder(text, callback) { + let div = document.createElement("div"); + div.classList.add("react-contextmenu-item"); + div.classList.add(scratchClass("context-menu_menu-item_")); + div.classList.add("ste-copy-paste-list"); + + feature.self.hideOnDisable(div); + + div.addEventListener("click", callback); + + div.role = "menuitem"; + div.tabIndex = "-1"; + div.ariaDisabled = false; + + let span = document.createElement("span"); + span.textContent = text; + div.appendChild(span); + + return div; + } + + async function getClipboardWithContextMenu() { + const input = document.createElement("input"); + input.style.position = "absolute"; + input.style.opacity = "0"; + document.body.appendChild(input); + + input.focus(); + + try { + const text = await navigator.clipboard.readText(); + return text; + } catch (err) { + console.log("Failed to read clipboard:", err); + } finally { + document.body.removeChild(input); + } + } + + // Credit to @mxmou on GitHub for findParentWithProp + + function findParentWithProp(reactInternalInstance, prop) { + if (!reactInternalInstance) return null; + while ( + !reactInternalInstance.stateNode?.props || + !Object.prototype.hasOwnProperty.call( + reactInternalInstance.stateNode.props, + prop + ) + ) { + if (!reactInternalInstance.return) return null; + reactInternalInstance = reactInternalInstance.return; + } + return reactInternalInstance.stateNode; + } + }); +} diff --git a/features/features.json b/features/features.json index 144b6446..a344f61e 100644 --- a/features/features.json +++ b/features/features.json @@ -1,4 +1,9 @@ [ + { + "version": 2, + "id": "copy-paste-lists", + "versionAdded": "v4.2.0" + }, { "version": 2, "id": "random-block-colors",