hydrooj-blockly-ide 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.npmignore +21 -0
- package/frontend/inject.page.tsx +5 -0
- package/package.json +15 -0
- package/public/blockly-addon.js +409 -0
package/.npmignore
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# 排除所有隱藏檔案與資料夾
|
|
2
|
+
.*
|
|
3
|
+
!/.npmignore
|
|
4
|
+
|
|
5
|
+
# 排除 CI 與開發工具設定
|
|
6
|
+
.github/
|
|
7
|
+
.vscode/
|
|
8
|
+
node_modules/
|
|
9
|
+
*.log
|
|
10
|
+
|
|
11
|
+
# 排除測試與原始碼 (如果你的 build 結果在 dist/ 或 lib/)
|
|
12
|
+
test/
|
|
13
|
+
tests/
|
|
14
|
+
src/
|
|
15
|
+
__tests__/
|
|
16
|
+
jest.config.js
|
|
17
|
+
tsconfig.json
|
|
18
|
+
|
|
19
|
+
# 排除 semantic-release 可能產生的檔案
|
|
20
|
+
release.config.js
|
|
21
|
+
.releaserc
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hydrooj-blockly-ide",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "HydroOJ blockly-ide module",
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"author": "Bryan0324",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
10
|
+
"@semantic-release/github": "^12.0.6",
|
|
11
|
+
"@semantic-release/npm": "^13.1.5",
|
|
12
|
+
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
13
|
+
"semantic-release": "^25.0.3"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/* global UiContext, UserContext */
|
|
2
|
+
(() => {
|
|
3
|
+
// ===================== config =====================
|
|
4
|
+
const BLOCKLY_KEY = 'blockly.py';
|
|
5
|
+
const BLOCKLY_DISPLAY = 'Blockly (Python)';
|
|
6
|
+
const SUBMIT_LANG = 'py';
|
|
7
|
+
const BLOCKLY_VERSION = '6.20210701.0';
|
|
8
|
+
|
|
9
|
+
const TOOLBOX_XML = `
|
|
10
|
+
<xml xmlns="https://developers.google.com/blockly/xml" id="toolbox">
|
|
11
|
+
<category name="Logic" colour="%{BKY_LOGIC_HUE}">
|
|
12
|
+
<block type="controls_if"></block>
|
|
13
|
+
<block type="logic_compare"></block>
|
|
14
|
+
<block type="logic_operation"></block>
|
|
15
|
+
<block type="logic_negate"></block>
|
|
16
|
+
<block type="logic_boolean"></block>
|
|
17
|
+
<block type="logic_null"></block>
|
|
18
|
+
<block type="logic_ternary"></block>
|
|
19
|
+
</category>
|
|
20
|
+
|
|
21
|
+
<category name="Loops" colour="%{BKY_LOOPS_HUE}">
|
|
22
|
+
<block type="controls_repeat_ext">
|
|
23
|
+
<value name="TIMES">
|
|
24
|
+
<block type="math_number"><field name="NUM">10</field></block>
|
|
25
|
+
</value>
|
|
26
|
+
</block>
|
|
27
|
+
<block type="controls_whileUntil"></block>
|
|
28
|
+
<block type="controls_for"></block>
|
|
29
|
+
<block type="controls_forEach"></block>
|
|
30
|
+
<block type="controls_flow_statements"></block>
|
|
31
|
+
</category>
|
|
32
|
+
|
|
33
|
+
<category name="Math" colour="%{BKY_MATH_HUE}">
|
|
34
|
+
<block type="math_number"><field name="NUM">123</field></block>
|
|
35
|
+
<block type="math_arithmetic"></block>
|
|
36
|
+
<block type="math_single"></block>
|
|
37
|
+
<block type="math_trig"></block>
|
|
38
|
+
<block type="math_constant"></block>
|
|
39
|
+
<block type="math_number_property"></block>
|
|
40
|
+
<block type="math_round"></block>
|
|
41
|
+
<block type="math_modulo"></block>
|
|
42
|
+
<block type="math_random_int"></block>
|
|
43
|
+
<block type="math_random_float"></block>
|
|
44
|
+
</category>
|
|
45
|
+
|
|
46
|
+
<category name="Text" colour="%{BKY_TEXTS_HUE}">
|
|
47
|
+
<block type="text"></block>
|
|
48
|
+
<block type="text_join"></block>
|
|
49
|
+
<block type="text_length"></block>
|
|
50
|
+
<block type="text_isEmpty"></block>
|
|
51
|
+
<block type="text_indexOf"></block>
|
|
52
|
+
<block type="text_charAt"></block>
|
|
53
|
+
<block type="text_getSubstring"></block>
|
|
54
|
+
<block type="text_changeCase"></block>
|
|
55
|
+
<block type="text_trim"></block>
|
|
56
|
+
<block type="text_print"></block>
|
|
57
|
+
<block type="text_prompt_ext"></block>
|
|
58
|
+
</category>
|
|
59
|
+
|
|
60
|
+
<category name="Lists" colour="%{BKY_LISTS_HUE}">
|
|
61
|
+
<block type="lists_create_empty"></block>
|
|
62
|
+
<block type="lists_create_with"></block>
|
|
63
|
+
<block type="lists_repeat">
|
|
64
|
+
<value name="NUM">
|
|
65
|
+
<block type="math_number"><field name="NUM">5</field></block>
|
|
66
|
+
</value>
|
|
67
|
+
</block>
|
|
68
|
+
<block type="lists_length"></block>
|
|
69
|
+
<block type="lists_isEmpty"></block>
|
|
70
|
+
<block type="lists_indexOf"></block>
|
|
71
|
+
<block type="lists_getIndex"></block>
|
|
72
|
+
<block type="lists_setIndex"></block>
|
|
73
|
+
</category>
|
|
74
|
+
|
|
75
|
+
<sep></sep>
|
|
76
|
+
<category name="Variables" custom="VARIABLE" colour="%{BKY_VARIABLES_HUE}"></category>
|
|
77
|
+
<category name="Functions" custom="PROCEDURE" colour="%{BKY_PROCEDURES_HUE}"></category>
|
|
78
|
+
</xml>`.trim();
|
|
79
|
+
|
|
80
|
+
const log = (...a) => console.log('[blockly-addon]', ...a);
|
|
81
|
+
|
|
82
|
+
// ===================== LANGS injection =====================
|
|
83
|
+
function ensureLangs() {
|
|
84
|
+
window.LANGS ||= {};
|
|
85
|
+
if (!window.LANGS[BLOCKLY_KEY]) {
|
|
86
|
+
window.LANGS[BLOCKLY_KEY] = {
|
|
87
|
+
display: BLOCKLY_DISPLAY,
|
|
88
|
+
monaco: 'blockly',
|
|
89
|
+
pretest: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 題目允許 py / py.* -> 自動允許 blockly.py
|
|
95
|
+
function expandProblemLangsForBlockly() {
|
|
96
|
+
try {
|
|
97
|
+
const langs = UiContext?.pdoc?.config?.langs;
|
|
98
|
+
if (!Array.isArray(langs)) return;
|
|
99
|
+
|
|
100
|
+
const hasPy = langs.some((x) => x === 'py' || (typeof x === 'string' && x.startsWith('py.')));
|
|
101
|
+
if (hasPy && !langs.includes(BLOCKLY_KEY)) langs.push(BLOCKLY_KEY);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ===================== store wait =====================
|
|
108
|
+
function isReduxStore(x) {
|
|
109
|
+
return x && typeof x.getState === 'function'
|
|
110
|
+
&& typeof x.dispatch === 'function'
|
|
111
|
+
&& typeof x.subscribe === 'function';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function waitForStore({ timeoutMs = 60000, intervalMs = 200 } = {}) {
|
|
115
|
+
const start = Date.now();
|
|
116
|
+
while (Date.now() - start < timeoutMs) {
|
|
117
|
+
if (isReduxStore(window.store)) return window.store;
|
|
118
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
119
|
+
}
|
|
120
|
+
throw new Error('Timed out waiting for window.store');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ===================== request lang mapping (blockly.py -> py) =====================
|
|
124
|
+
function tryRewriteBody(body) {
|
|
125
|
+
if (body == null) return body;
|
|
126
|
+
|
|
127
|
+
// JSON string
|
|
128
|
+
if (typeof body === 'string') {
|
|
129
|
+
const s = body.trim();
|
|
130
|
+
if (!s) return body;
|
|
131
|
+
if (s[0] === '{' || s[0] === '[') {
|
|
132
|
+
try {
|
|
133
|
+
const obj = JSON.parse(s);
|
|
134
|
+
if (obj && typeof obj === 'object' && obj.lang === BLOCKLY_KEY) {
|
|
135
|
+
obj.lang = SUBMIT_LANG;
|
|
136
|
+
return JSON.stringify(obj);
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {}
|
|
139
|
+
}
|
|
140
|
+
return body;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// FormData
|
|
144
|
+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
145
|
+
if (body.get('lang') === BLOCKLY_KEY) body.set('lang', SUBMIT_LANG);
|
|
146
|
+
return body;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// URLSearchParams
|
|
150
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
151
|
+
if (body.get('lang') === BLOCKLY_KEY) body.set('lang', SUBMIT_LANG);
|
|
152
|
+
return body;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return body;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function patchFetchAndXHR() {
|
|
159
|
+
// fetch
|
|
160
|
+
if (!window.__blocklyAddonFetchPatched && typeof window.fetch === 'function') {
|
|
161
|
+
window.__blocklyAddonFetchPatched = true;
|
|
162
|
+
const origFetch = window.fetch.bind(window);
|
|
163
|
+
window.fetch = async (input, init = {}) => {
|
|
164
|
+
try {
|
|
165
|
+
if (init && init.body != null) init = { ...init, body: tryRewriteBody(init.body) };
|
|
166
|
+
} catch (e) {}
|
|
167
|
+
return origFetch(input, init);
|
|
168
|
+
};
|
|
169
|
+
log('fetch patched');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// XHR
|
|
173
|
+
if (!window.__blocklyAddonXHRPatched && window.XMLHttpRequest) {
|
|
174
|
+
window.__blocklyAddonXHRPatched = true;
|
|
175
|
+
const origSend = window.XMLHttpRequest.prototype.send;
|
|
176
|
+
window.XMLHttpRequest.prototype.send = function patchedSend(body) {
|
|
177
|
+
try { body = tryRewriteBody(body); } catch (e) {}
|
|
178
|
+
return origSend.call(this, body);
|
|
179
|
+
};
|
|
180
|
+
log('xhr patched');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ===================== editor container finding =====================
|
|
185
|
+
function findEditorContainer() {
|
|
186
|
+
// 1) 最準:直接找 ScratchpadMonacoEditor(初始就是 blockly 時也存在)
|
|
187
|
+
const direct = document.querySelector('.ScratchpadMonacoEditor');
|
|
188
|
+
if (direct) return direct;
|
|
189
|
+
|
|
190
|
+
// 2) 若 monaco 已經存在,用 monaco dom node 往上找最近的 ScratchpadMonacoEditor
|
|
191
|
+
const monacoRoot = document.querySelector('.monaco-editor');
|
|
192
|
+
if (monacoRoot) {
|
|
193
|
+
const host = monacoRoot.closest('.ScratchpadMonacoEditor');
|
|
194
|
+
if (host) return host;
|
|
195
|
+
// 退一步用 parent
|
|
196
|
+
if (monacoRoot.parentElement) return monacoRoot.parentElement;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 3) 最後 fallback:找可能的 editor slot(避免掛到 #scratchpad 這種頁面根容器)
|
|
200
|
+
return document.querySelector('[data-scratchpad-editor], .scratchpad__editor') || null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ===================== Blockly loader (python only) =====================
|
|
204
|
+
async function loadBlockly() {
|
|
205
|
+
if (window.Blockly) return window.Blockly;
|
|
206
|
+
|
|
207
|
+
const base = `https://unpkg.com/blockly@${BLOCKLY_VERSION}/`;
|
|
208
|
+
|
|
209
|
+
// core
|
|
210
|
+
await loadScript(`${base}blockly_compressed.js`);
|
|
211
|
+
|
|
212
|
+
// preload en msg (avoid some constants/mixins undefined when blocks load)
|
|
213
|
+
try {
|
|
214
|
+
await loadScript(`${base}msg/en.js`);
|
|
215
|
+
if (window.Blockly && window.Blockly.Msg && typeof window.Blockly.setLocale === 'function') {
|
|
216
|
+
window.Blockly.setLocale(window.Blockly.Msg);
|
|
217
|
+
}
|
|
218
|
+
} catch (e) {
|
|
219
|
+
log('preload msg/en failed (continue)', e);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// blocks
|
|
223
|
+
await loadScript(`${base}blocks_compressed.js`);
|
|
224
|
+
|
|
225
|
+
// python generator
|
|
226
|
+
await loadScript(`${base}python_compressed.js`);
|
|
227
|
+
|
|
228
|
+
// user locale (optional)
|
|
229
|
+
const lang = (UserContext && (UserContext.viewLang || UserContext.lang)) || 'en';
|
|
230
|
+
const map = {
|
|
231
|
+
zh: 'zh-hans',
|
|
232
|
+
zh_CN: 'zh-hans',
|
|
233
|
+
zh_TW: 'zh-hant',
|
|
234
|
+
ko: 'ko',
|
|
235
|
+
ko_KR: 'ko',
|
|
236
|
+
en: 'en',
|
|
237
|
+
en_US: 'en',
|
|
238
|
+
};
|
|
239
|
+
const msg = map[lang] || 'en';
|
|
240
|
+
try {
|
|
241
|
+
if (msg !== 'en') await loadScript(`${base}msg/${msg}.js`);
|
|
242
|
+
if (window.Blockly && window.Blockly.Msg && typeof window.Blockly.setLocale === 'function') {
|
|
243
|
+
window.Blockly.setLocale(window.Blockly.Msg);
|
|
244
|
+
}
|
|
245
|
+
} catch (e) {
|
|
246
|
+
log('locale load failed, continue with en', e);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!window.Blockly) throw new Error('Blockly not loaded');
|
|
250
|
+
return window.Blockly;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function loadScript(src) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const existed = Array.from(document.scripts).some((s) => s.src === src);
|
|
256
|
+
if (existed) return resolve(null);
|
|
257
|
+
const el = document.createElement('script');
|
|
258
|
+
el.src = src;
|
|
259
|
+
el.async = true;
|
|
260
|
+
el.onload = () => resolve(null);
|
|
261
|
+
el.onerror = (err) => reject(err);
|
|
262
|
+
document.head.appendChild(el);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ===================== mount/unmount + codegen =====================
|
|
267
|
+
let mounted = false;
|
|
268
|
+
let workspace = null;
|
|
269
|
+
let blocklyDiv = null;
|
|
270
|
+
let unsubscribe = null;
|
|
271
|
+
let lastLangKey = null;
|
|
272
|
+
let ro = null;
|
|
273
|
+
|
|
274
|
+
function genPython(Blockly) {
|
|
275
|
+
const gen = Blockly.Python;
|
|
276
|
+
return gen.workspaceToCode(workspace);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function mountIfNeeded(store) {
|
|
280
|
+
const state = store.getState();
|
|
281
|
+
const langKey = state?.editor?.lang;
|
|
282
|
+
|
|
283
|
+
if (langKey === lastLangKey) return;
|
|
284
|
+
lastLangKey = langKey;
|
|
285
|
+
|
|
286
|
+
const isBlockly = (langKey === BLOCKLY_KEY);
|
|
287
|
+
|
|
288
|
+
if (!isBlockly) {
|
|
289
|
+
if (mounted) unmountBlockly();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (mounted) {
|
|
294
|
+
// already mounted, regenerate once
|
|
295
|
+
try {
|
|
296
|
+
const Blockly = window.Blockly;
|
|
297
|
+
const code = genPython(Blockly);
|
|
298
|
+
store.dispatch({ type: 'SCRATCHPAD_EDITOR_UPDATE_CODE', payload: code });
|
|
299
|
+
} catch (e) {}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const container = findEditorContainer();
|
|
304
|
+
if (!container) {
|
|
305
|
+
// editor DOM might not be ready yet
|
|
306
|
+
lastLangKey = null;
|
|
307
|
+
setTimeout(() => mountIfNeeded(store), 250);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// sanity check: avoid mounting on page root by mistake
|
|
311
|
+
if (container.id === 'scratchpad') {
|
|
312
|
+
lastLangKey = null;
|
|
313
|
+
setTimeout(() => mountIfNeeded(store), 200);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const Blockly = await loadBlockly();
|
|
317
|
+
|
|
318
|
+
// ensure container has measurable height
|
|
319
|
+
container.style.height = '100%';
|
|
320
|
+
container.style.minHeight = '60vh';
|
|
321
|
+
|
|
322
|
+
blocklyDiv = document.createElement('div');
|
|
323
|
+
blocklyDiv.className = 'hydro-blockly-root';
|
|
324
|
+
blocklyDiv.style.width = '100%';
|
|
325
|
+
blocklyDiv.style.height = '100%';
|
|
326
|
+
blocklyDiv.style.minHeight = '60vh';
|
|
327
|
+
blocklyDiv.style.background = '#fff';
|
|
328
|
+
|
|
329
|
+
// replace monaco DOM
|
|
330
|
+
container.innerHTML = '';
|
|
331
|
+
container.appendChild(blocklyDiv);
|
|
332
|
+
|
|
333
|
+
workspace = Blockly.inject(blocklyDiv, {
|
|
334
|
+
toolbox: TOOLBOX_XML,
|
|
335
|
+
// 建議之後自帶 media,避免外部 sprites
|
|
336
|
+
// media: '/static/blockly/media/',
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const doResize = () => {
|
|
340
|
+
try { Blockly.svgResize(workspace); } catch (e) {}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// observe resize
|
|
344
|
+
if (window.ResizeObserver) {
|
|
345
|
+
ro = new ResizeObserver(() => doResize());
|
|
346
|
+
ro.observe(container);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// initial resize after layout settles
|
|
350
|
+
requestAnimationFrame(() => requestAnimationFrame(doResize));
|
|
351
|
+
setTimeout(doResize, 0);
|
|
352
|
+
setTimeout(doResize, 200);
|
|
353
|
+
|
|
354
|
+
// change -> generate python -> write back to redux
|
|
355
|
+
workspace.addChangeListener(() => {
|
|
356
|
+
try {
|
|
357
|
+
const code = genPython(Blockly);
|
|
358
|
+
store.dispatch({ type: 'SCRATCHPAD_EDITOR_UPDATE_CODE', payload: code });
|
|
359
|
+
} catch (e) {
|
|
360
|
+
log('generate failed', e);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// initial generate once
|
|
365
|
+
try {
|
|
366
|
+
const code = genPython(Blockly);
|
|
367
|
+
store.dispatch({ type: 'SCRATCHPAD_EDITOR_UPDATE_CODE', payload: code });
|
|
368
|
+
} catch (e) {}
|
|
369
|
+
|
|
370
|
+
mounted = true;
|
|
371
|
+
log('Blockly mounted for', langKey);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function unmountBlockly() {
|
|
375
|
+
try {
|
|
376
|
+
if (ro) { ro.disconnect(); ro = null; }
|
|
377
|
+
} catch (e) {}
|
|
378
|
+
try {
|
|
379
|
+
if (workspace && typeof workspace.dispose === 'function') workspace.dispose();
|
|
380
|
+
} catch (e) {}
|
|
381
|
+
|
|
382
|
+
workspace = null;
|
|
383
|
+
mounted = false;
|
|
384
|
+
|
|
385
|
+
if (blocklyDiv && blocklyDiv.parentElement) blocklyDiv.parentElement.removeChild(blocklyDiv);
|
|
386
|
+
blocklyDiv = null;
|
|
387
|
+
|
|
388
|
+
log('Blockly unmounted');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ===================== bootstrap =====================
|
|
392
|
+
async function startWhenReady() {
|
|
393
|
+
ensureLangs();
|
|
394
|
+
expandProblemLangsForBlockly();
|
|
395
|
+
patchFetchAndXHR();
|
|
396
|
+
|
|
397
|
+
const store = await waitForStore({ timeoutMs: 60000, intervalMs: 200 });
|
|
398
|
+
|
|
399
|
+
if (unsubscribe) unsubscribe();
|
|
400
|
+
unsubscribe = store.subscribe(() => mountIfNeeded(store));
|
|
401
|
+
|
|
402
|
+
// initial
|
|
403
|
+
mountIfNeeded(store);
|
|
404
|
+
|
|
405
|
+
log('started (store ready)');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
startWhenReady().catch((e) => console.error('[blockly-addon] start failed:', e));
|
|
409
|
+
})();
|