Skip to content

Commit 055e6b7

Browse files
committed
Copy and Paste Lists
1 parent 76a6718 commit 055e6b7

File tree

4 files changed

+179
-0
lines changed

4 files changed

+179
-0
lines changed

api/feature.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,9 @@ class Feature {
9090

9191
return element[reactKey]
9292
}
93+
this.getInternalKey = function(element) {
94+
return Object.keys(element).find((key) => key.startsWith("__reactInternalInstance")) || null
95+
}
9396
this.redux = document.querySelector("#app")?.[
9497
Object.keys(app).find((key) => key.startsWith("__reactContainer"))
9598
].child.stateNode.store
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"title": "Copy and Paste Lists",
3+
"description": "Allows you to right-click on lists on the stage to copy and paste large amounts of items.",
4+
"credits": [
5+
{
6+
"username": "Brass_Glass",
7+
"url": "https://scratch.mit.edu/users/Brass_Glass/"
8+
},
9+
{ "username": "rgantzos", "url": "https://scratch.mit.edu/users/rgantzos/" }
10+
],
11+
"type": ["Editor"],
12+
"tags": ["New", "Featured"],
13+
"dynamic": true,
14+
"scripts": [{ "file": "script.js", "runOn": "/projects/*" }]
15+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
export default async function ({ feature, console, scratchClass }) {
2+
document.body.addEventListener("contextmenu", async (event) => {
3+
const ctxTarget = event.target.closest(
4+
".react-contextmenu-wrapper, [data-state]"
5+
);
6+
if (!ctxTarget) return;
7+
const ctx = feature.getInternals(ctxTarget);
8+
9+
if (!ctx) return;
10+
11+
const listCtx = findParentWithProp(ctx, "opcode");
12+
if (listCtx && listCtx.props.id) {
13+
let listId = listCtx.props.id;
14+
15+
let stage = feature.traps.vm.runtime.getTargetForStage();
16+
let list = stage.lookupVariableById(listId);
17+
18+
if (list.type !== "list") return;
19+
20+
const menuInternal =
21+
feature.getInternals(ctxTarget).return.stateNode.props.id;
22+
if (!menuInternal) return;
23+
24+
let menus = document.querySelectorAll("body > nav.react-contextmenu");
25+
26+
let menu = Array.prototype.find.call(menus, (pMenu) => {
27+
const menuInternals = feature.getInternals(pMenu);
28+
return menuInternals?.return?.stateNode?.props?.id === menuInternal;
29+
});
30+
31+
if (menu.querySelector(".ste-copy-paste-list")) return;
32+
33+
menu.prepend(
34+
optionBuilder("paste", async function () {
35+
try {
36+
let text = await getClipboardWithContextMenu();
37+
if (text) {
38+
let newItems = text.split("\n");
39+
40+
if (
41+
confirm(
42+
`Are you sure you want to add ${newItems.length} item${
43+
newItems.length === 1 ? "s" : ""
44+
} to your "${
45+
stage.lookupVariableById(listId).name
46+
}" list? This will clear all existing items.`
47+
)
48+
) {
49+
stage.lookupVariableById(listId).value = newItems || [];
50+
51+
updateList(listId);
52+
53+
alert(
54+
`Successfully pasted ${newItems.length} items to your "${
55+
stage.lookupVariableById(listId).name
56+
}" list!`
57+
);
58+
}
59+
} else {
60+
alert("Oops! You don't have anything copied!");
61+
}
62+
} catch (err) {
63+
alert("Oops! Something went wrong.");
64+
}
65+
closeContextMenu();
66+
})
67+
);
68+
69+
menu.prepend(
70+
optionBuilder("copy", async function () {
71+
let text = stage.lookupVariableById(listId).value.join("\n");
72+
await navigator.clipboard.writeText(text);
73+
closeContextMenu();
74+
})
75+
);
76+
77+
window.menu = menu;
78+
}
79+
80+
function updateList(id) {
81+
feature.traps.vm.runtime.requestUpdateMonitor(
82+
new Map([
83+
["id", id],
84+
["x", Date.now()],
85+
["y", 0],
86+
])
87+
);
88+
}
89+
90+
function closeContextMenu() {
91+
const clickEvent = new MouseEvent("mousedown", {
92+
bubbles: true,
93+
cancelable: true,
94+
view: window,
95+
});
96+
97+
document.body.dispatchEvent(clickEvent);
98+
}
99+
100+
function optionBuilder(text, callback) {
101+
let div = document.createElement("div");
102+
div.classList.add("react-contextmenu-item");
103+
div.classList.add(scratchClass("context-menu_menu-item_"));
104+
div.classList.add("ste-copy-paste-list");
105+
106+
feature.self.hideOnDisable(div);
107+
108+
div.addEventListener("click", callback);
109+
110+
div.role = "menuitem";
111+
div.tabIndex = "-1";
112+
div.ariaDisabled = false;
113+
114+
let span = document.createElement("span");
115+
span.textContent = text;
116+
div.appendChild(span);
117+
118+
return div;
119+
}
120+
121+
async function getClipboardWithContextMenu() {
122+
const input = document.createElement("input");
123+
input.style.position = "absolute";
124+
input.style.opacity = "0";
125+
document.body.appendChild(input);
126+
127+
input.focus();
128+
129+
try {
130+
const text = await navigator.clipboard.readText();
131+
return text;
132+
} catch (err) {
133+
console.log("Failed to read clipboard:", err);
134+
} finally {
135+
document.body.removeChild(input);
136+
}
137+
}
138+
139+
// Credit to @mxmou on GitHub for findParentWithProp
140+
141+
function findParentWithProp(reactInternalInstance, prop) {
142+
if (!reactInternalInstance) return null;
143+
while (
144+
!reactInternalInstance.stateNode?.props ||
145+
!Object.prototype.hasOwnProperty.call(
146+
reactInternalInstance.stateNode.props,
147+
prop
148+
)
149+
) {
150+
if (!reactInternalInstance.return) return null;
151+
reactInternalInstance = reactInternalInstance.return;
152+
}
153+
return reactInternalInstance.stateNode;
154+
}
155+
});
156+
}

features/features.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
[
2+
{
3+
"version": 2,
4+
"id": "copy-paste-lists",
5+
"versionAdded": "v4.2.0"
6+
},
27
{
38
"version": 2,
49
"id": "random-block-colors",

0 commit comments

Comments
 (0)