Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/feature.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions features/copy-paste-lists/data.json
Original file line number Diff line number Diff line change
@@ -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/*" }]
}
156 changes: 156 additions & 0 deletions features/copy-paste-lists/script.js
Original file line number Diff line number Diff line change
@@ -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;
}
});
}
5 changes: 5 additions & 0 deletions features/features.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
[
{
"version": 2,
"id": "copy-paste-lists",
"versionAdded": "v4.2.0"
},
{
"version": 2,
"id": "random-block-colors",
Expand Down
Loading