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 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
@@ -0,0 +1,5 @@
1
+ import { addPage, NamedPage, AutoloadPage } from '@hydrooj/ui-default';
2
+
3
+ addPage(new NamedPage('problem_detail', async () => {
4
+ document.body.appendChild(document.createRange().createContextualFragment(`<script src='/blockly-addon.js'></script>`));
5
+ }));
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
+ })();