jsmdcui 0.7.0 → 0.8.0
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/CHANGELOG.md +61 -0
- package/README.md +112 -18
- package/demos/maze.md +144 -0
- package/demos/todo-zh.md +98 -0
- package/demos/todo.md +98 -0
- package/edit +9 -0
- package/package.json +1 -1
- package/runmd.mjs +44 -2
- package/runtime/help/help.md +112 -18
- package/runtime/jsplugins/cdp/cdp.js +5 -2
- package/runtime/jsplugins/chapter/chapter.js +4 -2
- package/runtime/jsplugins/example/example.js +10 -7
- package/runtime/syntax/markdown.yaml +1 -0
- package/src/cui/fence-events.mjs +106 -0
- package/src/cui/id-collision.mjs +10 -28
- package/src/cui/kitty-images.mjs +11 -1
- package/src/cui/rpc.mjs +6 -3
- package/src/cui/server.mjs +13 -2
- package/src/index.js +379 -62
- package/src/platform/terminal.js +5 -0
- package/src/plugins/js-bridge.js +114 -8
- package/tests/fence-events.test.js +405 -0
- package/tests/id-collision.test.js +11 -0
- package/tests/js-plugin-prompts.test.js +46 -0
- package/tests/key-event.md +10 -0
- package/tests/kitty-images.test.js +22 -0
- package/tests/platform-terminal.test.js +8 -0
- package/tests/textarea.md +8 -0
- package/tests/wui.test.js +16 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,67 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-visible changes to jsmdcui are documented here.
|
|
4
4
|
|
|
5
|
+
## [0.8.0] - 2026-07-20
|
|
6
|
+
|
|
7
|
+
This update adds portable inline keydown handling to Markdown text controls,
|
|
8
|
+
including mobile software-keyboard fallback behavior, and makes interactive
|
|
9
|
+
prompts safe across every TUI frontend action path.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Add quoted `@keydown="..."` attributes to named `text` and `textarea`
|
|
14
|
+
fences. The TUI preserves source event metadata and evaluates it before key
|
|
15
|
+
handling; the WUI emits a native inline event attribute. Add `.prevent` to
|
|
16
|
+
apply `event.preventDefault()` before the handler runs. Keyup is intentionally
|
|
17
|
+
not exposed because traditional terminals do not report key releases
|
|
18
|
+
reliably.
|
|
19
|
+
- In the WUI, automatically map `beforeinput.data` back through `onkeydown`
|
|
20
|
+
when a mobile software keyboard reports `event.key` as `Unidentified`.
|
|
21
|
+
Unidentified events are hidden from application handlers, while identified
|
|
22
|
+
desktop keydowns are marked briefly to prevent duplicate calls.
|
|
23
|
+
- Add native multiline editing to TUI `textarea` controls. Enter splits a body
|
|
24
|
+
row and expands the frame; Backspace at the start of a later row and Delete
|
|
25
|
+
at the end of a row join adjacent body rows without exposing frame borders.
|
|
26
|
+
- Add the `bun ./edit` launcher as a short equivalent of
|
|
27
|
+
`bun src/index.js --edit`, opening Markdown as ordinary editable UTF-8 source.
|
|
28
|
+
- Add the automatically discovered `--demo-maze` example, demonstrating
|
|
29
|
+
portable `@keydown.prevent` controls with keyboard and arrow-key navigation;
|
|
30
|
+
add `Ctrl-R` as a keyboard shortcut for resetting the maze.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Install protected `alert`, `confirm`, and `prompt` globals once for the TUI
|
|
35
|
+
lifetime and share them across OSC 8 actions, fenced keyboard handlers, and
|
|
36
|
+
JS plugins. Restore Bun's original globals when the TUI stops.
|
|
37
|
+
- Make `micro.alert()`, `micro.confirm()`, and `micro.prompt()` synchronous like
|
|
38
|
+
their native counterparts, and update the bundled JS plugin examples so they
|
|
39
|
+
do not `await` these calls.
|
|
40
|
+
- Add TUI `.val(value)` replacements to Undo/Redo history, including multiline
|
|
41
|
+
layout, ANSI rendering metadata, image positions, and task-list anchors.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- Prevent `alert()` called from a keydown handler from competing with the TUI
|
|
46
|
+
for stdin by suspending raw mode and screen rendering around native prompts.
|
|
47
|
+
- Restore the terminal's previous raw-mode and input-listener state after a
|
|
48
|
+
protected prompt, including setup, prompt, and cleanup error paths.
|
|
49
|
+
- Index fenced keyboard-event regions once and use binary lookup for each key,
|
|
50
|
+
instead of rescanning the rendered Markdown document on every keypress.
|
|
51
|
+
- Open Windows controlling-terminal input through read-only `CONIN$` when stdin
|
|
52
|
+
is redirected, so piped Markdown retains keyboard and protected-prompt input.
|
|
53
|
+
- Preserve `ctrlKey`, `shiftKey`, `altKey`, and `metaKey` when the WUI maps an
|
|
54
|
+
unidentified mobile keydown through its `beforeinput` fallback.
|
|
55
|
+
- Recover modified A-Z keys in the WUI from key codes 65-90 or physical
|
|
56
|
+
`KeyA`-`KeyZ` codes, including Android browsers that transform Alt-S into
|
|
57
|
+
`ß`; exclude AltGraph so international text composition remains intact.
|
|
58
|
+
- Add matching non-enumerable `event.toJSON()` methods to TUI and WUI keydown
|
|
59
|
+
events, allowing `JSON.stringify(event)` to produce portable event details.
|
|
60
|
+
- Keep fenced-block IDs visible to collision checking when the info string also
|
|
61
|
+
contains inline event attributes such as `@keydown="..."`.
|
|
62
|
+
- When the WUI's default port 3000 is already in use, retry with port 0 so the
|
|
63
|
+
operating system selects an available port, and print that actual port in the
|
|
64
|
+
generated URL. Other server startup errors continue to fail normally.
|
|
65
|
+
|
|
5
66
|
## [0.7.0] - 2026-07-20
|
|
6
67
|
|
|
7
68
|
This update adds native Kitty image rendering, turns heading task lists into
|
package/README.md
CHANGED
|
@@ -89,6 +89,11 @@ npx jsmdcui --demo-imgtool
|
|
|
89
89
|
npx jsmdcui --demo-imgtool-zh
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
+
```sh
|
|
93
|
+
# Maze game demo
|
|
94
|
+
npx jsmdcui --demo-maze
|
|
95
|
+
```
|
|
96
|
+
|
|
92
97
|
```sh
|
|
93
98
|
# WUI(Web User Interface) Demo
|
|
94
99
|
npx jsmdcui --wui
|
|
@@ -134,7 +139,7 @@ bun src/index.js --wui testapp.md
|
|
|
134
139
|
| --- | --- |
|
|
135
140
|
| `bun src/index.js app.md` | Render `app.md` as a read-only terminal UI and write five generated files beside it. |
|
|
136
141
|
| `bun src/index.js --kitty app.md` | Display Markdown images with Kitty graphics and the jsgotty MIME extension. |
|
|
137
|
-
| `bun src/index.js --kitty-compat app.md` |
|
|
142
|
+
| `bun src/index.js --kitty-compat app.md` | Convert Markdown images to PNG with `Bun.Image` and display them using the standard Kitty graphics protocol without the non-standard MIME `U` field. |
|
|
138
143
|
| `bun src/index.js --kitty --allow-url URL.md` | Download trusted HTTP(S) Markdown and its HTTP(S) images, then display supported images with Kitty graphics. |
|
|
139
144
|
| `JSMDCUI_KITTY_DEBUG=1 bun src/index.js --kitty app.md` | Enable Kitty image placement logging to `kitty-placement.log`. |
|
|
140
145
|
| `bun src/index.js --check app.md` | Check heading and fenced-block IDs for collisions, print line-by-line details, and exit. |
|
|
@@ -239,12 +244,81 @@ native `<textarea>` with the declared ID and classes. Long text wraps
|
|
|
239
244
|
automatically, and the field height is recalculated when the user types, the
|
|
240
245
|
window is resized, or frontend code calls `.val(value)`.
|
|
241
246
|
|
|
247
|
+
In the TUI, `text` remains a single-line control while `textarea` supports
|
|
248
|
+
native multiline editing. Enter splits the current body row and grows the
|
|
249
|
+
frame, Backspace at the start of a later row joins it to the previous row, and
|
|
250
|
+
Delete at the end of a row joins the following row. When the expanded control
|
|
251
|
+
does not fit on screen, the document viewport scrolls to keep the cursor
|
|
252
|
+
visible. The closing border and following Markdown content move with the
|
|
253
|
+
resized control.
|
|
254
|
+
|
|
255
|
+
A named `text` or `textarea` block can run inline front-end code before it
|
|
256
|
+
handles a key by placing a quoted HTML-style `@keydown` attribute after its
|
|
257
|
+
identity:
|
|
258
|
+
|
|
259
|
+
````md
|
|
260
|
+
```text#command.field @keydown="handleCommand(event)"
|
|
261
|
+
Initial value
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```js front
|
|
265
|
+
export function handleCommand(event) {
|
|
266
|
+
if (event.key !== 'Enter') return;
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
alert(`Command: ${$('#command').val()}`);
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
````
|
|
272
|
+
|
|
273
|
+
The block must have a unique ID. In the WUI, jsmdcui writes the code directly
|
|
274
|
+
to the generated textarea's native `onkeydown` attribute. Mobile browsers may
|
|
275
|
+
report ordinary software-keyboard letters as `Unidentified`, so the WUI also
|
|
276
|
+
generates an `onbeforeinput` fallback. The unidentified event is hidden from
|
|
277
|
+
the handler; `beforeinput.data` becomes `event.key`, then the same keydown code
|
|
278
|
+
runs once. The fallback also preserves `ctrlKey`, `shiftKey`, `altKey`, and
|
|
279
|
+
`metaKey` from the unidentified keydown. A short zero-delay timer distinguishes
|
|
280
|
+
it from the `beforeinput` that normally follows an already identified keydown,
|
|
281
|
+
preventing duplicate calls.
|
|
282
|
+
|
|
283
|
+
On Android browsers, `Alt-E`, `Alt-N`, `Alt-U`, and `Alt-I` may be consumed or
|
|
284
|
+
transformed by the software keyboard and therefore cannot always be observed
|
|
285
|
+
reliably by a keydown handler. Avoid relying on these combinations for portable
|
|
286
|
+
WUI controls.
|
|
287
|
+
|
|
288
|
+
In the TUI, the full source info string is kept in an event table before Bun
|
|
289
|
+
renders it. The keydown statements run with a synthetic `event` before the
|
|
290
|
+
text control handles the key. Both interfaces expose `event.key`, modifier
|
|
291
|
+
flags, `event.target.id`, and `event.target.value`. Use `event`, the native
|
|
292
|
+
inline-handler variable, rather than Vue's `$event` alias. Double quotes
|
|
293
|
+
delimit the handler; use single-quoted JavaScript strings inside it or escape
|
|
294
|
+
an embedded double quote as `\"`.
|
|
295
|
+
|
|
296
|
+
Both interfaces add a non-enumerable `event.toJSON()` method with matching
|
|
297
|
+
keyboard, modifier, prevention, and target fields. `JSON.stringify(event)`
|
|
298
|
+
calls it automatically, so the resulting JSON is portable between the TUI and
|
|
299
|
+
WUI.
|
|
300
|
+
|
|
301
|
+
A keydown handler can call `event.preventDefault()` to stop text insertion or
|
|
302
|
+
cursor movement. The `.prevent` modifier applies this automatically:
|
|
303
|
+
|
|
304
|
+
````md
|
|
305
|
+
```text#command @keydown.prevent="handleCommand(event)"
|
|
306
|
+
```
|
|
307
|
+
````
|
|
308
|
+
|
|
309
|
+
`@keydown` is the only keyboard event exposed by jsmdcui. Traditional terminal
|
|
310
|
+
input does not report physical key releases reliably, so jsmdcui does not
|
|
311
|
+
provide or emulate `@keyup` in either interface.
|
|
312
|
+
|
|
242
313
|
In the terminal TUI, only content after the protected `│ ` or `| ` prefix can
|
|
243
|
-
be edited
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
314
|
+
be edited, and the frame prefix cannot be deleted. Multiline paste remains
|
|
315
|
+
blocked. For single-line `text` controls, activate the lower-left frame corner
|
|
316
|
+
to add a row. Activate the upper-left frame corner to remove the trailing row
|
|
317
|
+
only when it is empty; non-empty content is never removed.
|
|
318
|
+
|
|
319
|
+
TUI text-control changes made through `.val(value)` participate in the same
|
|
320
|
+
history as direct editing. `Ctrl-Z` undoes the replacement and `Ctrl-Y` redoes
|
|
321
|
+
it, including multiline frame resizing and its associated rendered metadata.
|
|
248
322
|
|
|
249
323
|
### Heading task lists and selector API
|
|
250
324
|
|
|
@@ -384,7 +458,7 @@ The available selector methods are:
|
|
|
384
458
|
| Method | TUI | WUI |
|
|
385
459
|
| --- | --- | --- |
|
|
386
460
|
| `.val()` | Read text blocks or heading task-list values. | Read textareas/controls or heading task-list values. |
|
|
387
|
-
| `.val(value)` | Replace text-block contents
|
|
461
|
+
| `.val(value)` | Replace text-block contents, resize multiline values, and record Undo/Redo history. | Set textarea/control values and resize textareas. |
|
|
388
462
|
| `.html()` | Return a selected heading's inner HTML. | Return any successfully selected DOM element's `innerHTML`. |
|
|
389
463
|
| `.line()` | Return a heading's current 1-based TUI row, or `0` if missing. | Not available. |
|
|
390
464
|
| `.push(...items)` | Append unchecked strings or `{ value, checked }` task items; return the new direct-item count. | Same. |
|
|
@@ -397,16 +471,16 @@ The available selector methods are:
|
|
|
397
471
|
TUI heading rows are recalculated after text-block rows are added, removed, or
|
|
398
472
|
replaced with multiline `.val(value)` content.
|
|
399
473
|
|
|
400
|
-
The
|
|
474
|
+
The 3 UI building blocks are:
|
|
401
475
|
|
|
402
|
-
- Regular Markdown provides headings, text, lists, task checkboxes, code, and
|
|
476
|
+
- 1. `Regular Markdown` provides headings, text, lists, task checkboxes, code, and
|
|
403
477
|
links.
|
|
404
|
-
-
|
|
478
|
+
- 2. `js front` block contains UI code. Exported functions can use
|
|
405
479
|
`alert`, `confirm`, `prompt`, and the generated `rpc` client.
|
|
406
|
-
|
|
407
|
-
The terminal UI awaits it before closing an `mdcui` buffer.
|
|
408
|
-
`mdcui` buffers close without a save prompt.
|
|
409
|
-
-
|
|
480
|
+
* A front module may export `async function onMdcuiExit({ reason, path, $ })`.
|
|
481
|
+
* The terminal UI awaits it before closing an `mdcui` buffer.
|
|
482
|
+
* Modified `mdcui` buffers close without a save prompt.
|
|
483
|
+
- 3. `js back` block exports trusted backend functions. In the browser WUI,
|
|
410
484
|
`rpc` publishes only exported functions whose exported names do not start
|
|
411
485
|
with `_`. Call a published function from the front end with
|
|
412
486
|
`await rpc.functionName(arg1, arg2)`.
|
|
@@ -489,15 +563,35 @@ its associated text. `alert`, `confirm`, and `prompt` use the browser's built-in
|
|
|
489
563
|
dialogs. Checkbox changes exist only in the current page: refreshing does not
|
|
490
564
|
preserve them and does not update the Markdown file.
|
|
491
565
|
|
|
492
|
-
The WUI
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
566
|
+
The WUI first tries port `3000` and accepts connections through the machine's
|
|
567
|
+
available network interfaces. If port `3000` is already in use, the operating
|
|
568
|
+
system selects an available port instead; the printed URL always contains the
|
|
569
|
+
actual port. Set `PORT` to request another fixed port. The printed `localhost`
|
|
570
|
+
URL is for the same machine. From another device on the same network, replace
|
|
571
|
+
`localhost` with the server machine's IP address and keep the printed port and
|
|
572
|
+
full path.
|
|
496
573
|
|
|
497
574
|
Each server start prints a new random path. The old URL stops working after the
|
|
498
575
|
server is stopped or restarted. Keep the process running while using the page,
|
|
499
576
|
and press `Ctrl-C` in its terminal to stop it.
|
|
500
577
|
|
|
578
|
+
## Editing Markdown source
|
|
579
|
+
|
|
580
|
+
From a cloned repository, use the `edit` launcher to open a Markdown file as
|
|
581
|
+
ordinary editable UTF-8 source instead of rendering it as a Markdown UI:
|
|
582
|
+
|
|
583
|
+
```sh
|
|
584
|
+
bun ./edit app.md
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
This is equivalent to:
|
|
588
|
+
|
|
589
|
+
```sh
|
|
590
|
+
bun src/index.js --edit app.md
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Additional file and cursor arguments are forwarded unchanged.
|
|
594
|
+
|
|
501
595
|
## Generated files
|
|
502
596
|
|
|
503
597
|
Opening a local Markdown UI generates these files beside the source file:
|
package/demos/maze.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
```text#character
|
|
2
|
+
🧱😀🧱🧱🧱🧱🧱🧱🧱🧱🧱🧱
|
|
3
|
+
🧱 🧱 🧱
|
|
4
|
+
🧱 🧱🧱🧱 🧱 🧱🧱🧱🧱
|
|
5
|
+
🧱 🧱 🧱 🧱
|
|
6
|
+
🧱🧱🧱 🧱🧱🧱🧱🧱 🧱🧱
|
|
7
|
+
🧱 🧱 🧱 🧱
|
|
8
|
+
🧱 🧱🧱🧱 🧱 🧱 🧱🧱
|
|
9
|
+
🧱 🧱 🧱 🧱
|
|
10
|
+
🧱 🧱🧱🧱🧱🧱🧱🧱 🧱🧱
|
|
11
|
+
🧱 🧱 🧱
|
|
12
|
+
🧱 🧱 🧱🧱🧱 🧱🧱🧱🧱
|
|
13
|
+
🧱 🧱 🧱
|
|
14
|
+
🧱🧱🧱🧱🧱🧱🧱🧱🧱 🧱🧱
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```text#controls @keydown.prevent="handle(event)"
|
|
18
|
+
Put the cursor here, then press:
|
|
19
|
+
將遊標放在這裡,然後按:
|
|
20
|
+
|
|
21
|
+
L't half of QWERTY ← move left
|
|
22
|
+
R't half of QWERTY → move right
|
|
23
|
+
T / Y ↑ move up
|
|
24
|
+
Space ↓ move down
|
|
25
|
+
|
|
26
|
+
鍵盤左半邊 ← 向左移動
|
|
27
|
+
鍵盤右半邊 → 向右移動
|
|
28
|
+
T / Y 向上移動
|
|
29
|
+
空白鍵向下移動
|
|
30
|
+
|
|
31
|
+
Arrow keys ←↑→↓ / 正常方向移動
|
|
32
|
+
Ctrl-R Reset maze / 重新開始
|
|
33
|
+
|
|
34
|
+
Do not hit 🧱 / 不能撞牆
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```text#last-key
|
|
38
|
+
Waiting for input / 等待輸入
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
[🔄 Reset maze / 重新開始](javascript:reset())
|
|
42
|
+
|
|
43
|
+
```js front
|
|
44
|
+
const LEFT_KEYS = new Set([
|
|
45
|
+
...'`12345qwerasdfgzxcvb',
|
|
46
|
+
'escape', 'tab',
|
|
47
|
+
]);
|
|
48
|
+
const RIGHT_KEYS = new Set([
|
|
49
|
+
...'67890-=uiop[]\\hjkl;\'nm,./',
|
|
50
|
+
'backspace', 'enter', 'delete',
|
|
51
|
+
'home', 'end', 'pageup', 'pagedown',
|
|
52
|
+
]);
|
|
53
|
+
const MAZE = [
|
|
54
|
+
'# ##########',
|
|
55
|
+
'# # #',
|
|
56
|
+
'# ### # ####',
|
|
57
|
+
'# # # #',
|
|
58
|
+
'### ##### ##',
|
|
59
|
+
'# # # #',
|
|
60
|
+
'# ### # # ##',
|
|
61
|
+
'# # # #',
|
|
62
|
+
'# ####### ##',
|
|
63
|
+
'# # #',
|
|
64
|
+
'# # ### ####',
|
|
65
|
+
'# # #',
|
|
66
|
+
'######### ##',
|
|
67
|
+
];
|
|
68
|
+
const GOAL = { row: MAZE.length - 1, col: 9 };
|
|
69
|
+
const player = { row: 0, col: 1 };
|
|
70
|
+
let completed = false;
|
|
71
|
+
|
|
72
|
+
function drawMaze() {
|
|
73
|
+
const screen = MAZE.map((line, row) => [...line].map((cell, col) => {
|
|
74
|
+
if (row === player.row && col === player.col) return '😀';
|
|
75
|
+
return cell === '#' ? '🧱' : ' ';
|
|
76
|
+
}).join('')).join('\n');
|
|
77
|
+
$('#character').val(screen);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function reset() {
|
|
81
|
+
player.row = 0;
|
|
82
|
+
player.col = 1;
|
|
83
|
+
completed = false;
|
|
84
|
+
drawMaze();
|
|
85
|
+
$('#last-key').val('Waiting for input / 等待輸入');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function handle(event) {
|
|
89
|
+
const key = String(event.key ?? '').toLowerCase();
|
|
90
|
+
if (event.ctrlKey && key === 'r') {
|
|
91
|
+
reset();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (completed) return;
|
|
95
|
+
|
|
96
|
+
let row = player.row;
|
|
97
|
+
let col = player.col;
|
|
98
|
+
let direction;
|
|
99
|
+
|
|
100
|
+
if (key === 't' || key === 'y' || key === 'arrowup') {
|
|
101
|
+
row--;
|
|
102
|
+
direction = 'up / 上';
|
|
103
|
+
} else if (key === ' ' || key === 'arrowdown') {
|
|
104
|
+
row++;
|
|
105
|
+
direction = 'down / 下';
|
|
106
|
+
} else if (key === 'arrowleft' || LEFT_KEYS.has(key)) {
|
|
107
|
+
col--;
|
|
108
|
+
direction = 'left / 左';
|
|
109
|
+
} else if (key === 'arrowright' || RIGHT_KEYS.has(key)) {
|
|
110
|
+
col++;
|
|
111
|
+
direction = 'right / 右';
|
|
112
|
+
} else {
|
|
113
|
+
$('#last-key').val(`Ignored / 忽略:${event.key}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (MAZE[row]?.[col] !== ' ') {
|
|
118
|
+
$('#last-key').val(`${event.key === ' ' ? 'Space' : event.key} → 🧱 Wall / 撞牆`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
player.row = row;
|
|
123
|
+
player.col = col;
|
|
124
|
+
drawMaze();
|
|
125
|
+
|
|
126
|
+
if (row === GOAL.row && col === GOAL.col) {
|
|
127
|
+
completed = true;
|
|
128
|
+
$('#last-key').val('🎉🏆🌟 Escaped the maze! / 成功走出迷宮!🌟🏆🎉');
|
|
129
|
+
alert([
|
|
130
|
+
'🎆🎇🎉 恭 喜 過 關 ! 🎉🎇🎆',
|
|
131
|
+
'',
|
|
132
|
+
'🥳 你成功逃出迷宮了! 🥳',
|
|
133
|
+
'👑 🏆 ⭐ 🌟 💫 🌈 🦄',
|
|
134
|
+
'👏👏👏 AMAZING! 👏👏👏',
|
|
135
|
+
'🎈 🎊 🎁 🪩 🎁 🎊 🎈',
|
|
136
|
+
'',
|
|
137
|
+
'🚀 ESCAPED THE MAZE! 🚀',
|
|
138
|
+
].join('\n'));
|
|
139
|
+
} else {
|
|
140
|
+
const label = event.key === ' ' ? 'Space' : event.key;
|
|
141
|
+
$('#last-key').val(`${label} → ${direction} (${row}, ${col})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
package/demos/todo-zh.md
CHANGED
|
@@ -18,10 +18,25 @@ Fly to the moon
|
|
|
18
18
|
在上方輸入 Todo 文字,再選擇新增或移除。移除會刪除第一個文字完全相同的項目。
|
|
19
19
|
|
|
20
20
|
- [新增 Todo](javascript:addTodo())
|
|
21
|
+
- .
|
|
21
22
|
- [移除 Todo](javascript:removeTodo())
|
|
23
|
+
- .
|
|
22
24
|
- [顯示已完成](javascript:showCompleted())
|
|
25
|
+
- .
|
|
23
26
|
- [顯示未完成](javascript:showPending())
|
|
24
27
|
|
|
28
|
+
## 存檔/讀檔
|
|
29
|
+
|
|
30
|
+
```text#todo-file
|
|
31
|
+
todo_list.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
在上方輸入 JSON 檔案路徑。存檔會寫入人類可讀的 JSON;讀檔會在確認後取代目前的 Todo 清單。
|
|
35
|
+
|
|
36
|
+
- [儲存 Todos](javascript:saveTodos())
|
|
37
|
+
- .
|
|
38
|
+
- [讀取 Todos](javascript:loadTodos())
|
|
39
|
+
|
|
25
40
|
## 操作結果:
|
|
26
41
|
|
|
27
42
|
```text#todo-status
|
|
@@ -51,6 +66,14 @@ function showItems(title, checked) {
|
|
|
51
66
|
$('#todo-status').val(`找到 ${items.length} 個${title}項目`);
|
|
52
67
|
}
|
|
53
68
|
|
|
69
|
+
function todoFile() {
|
|
70
|
+
return $('#todo-file').val().trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function describeError(error) {
|
|
74
|
+
return error?.message || String(error);
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
export function addTodo() {
|
|
55
78
|
const value = todoText();
|
|
56
79
|
if (!value) {
|
|
@@ -89,4 +112,79 @@ export function showCompleted() {
|
|
|
89
112
|
export function showPending() {
|
|
90
113
|
showItems('未完成', false);
|
|
91
114
|
}
|
|
115
|
+
|
|
116
|
+
export async function saveTodos() {
|
|
117
|
+
const file = todoFile();
|
|
118
|
+
if (!file) {
|
|
119
|
+
$('#todo-status').val('存檔失敗:請先輸入 JSON 檔案路徑');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const obj = { todos: $('#todos').slice() };
|
|
125
|
+
const result = await rpc.saveTodoList(file, obj);
|
|
126
|
+
$('#todo-status').val(`已將 ${obj.todos.length} 個 Todo 儲存至 ${result.path}`);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
$('#todo-status').val(`存檔失敗:${describeError(error)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function loadTodos() {
|
|
133
|
+
const file = todoFile();
|
|
134
|
+
if (!file) {
|
|
135
|
+
$('#todo-status').val('讀檔失敗:請先輸入 JSON 檔案路徑');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const result = await rpc.loadTodoList(file);
|
|
140
|
+
const json = JSON.stringify(result.obj, null, 1);
|
|
141
|
+
if (!confirm(`要用「${file}」中的以下 JSON 取代目前的 Todo 清單嗎?\n\n${json}`)) {
|
|
142
|
+
$('#todo-status').val('已取消讀檔,目前的 Todos 未變更');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const current = $('#todos').slice();
|
|
146
|
+
$('#todos').splice(0, current.length, ...result.obj.todos);
|
|
147
|
+
$('#todo-status').val(`已從 ${result.path} 讀取 ${result.obj.todos.length} 個 Todo`);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
$('#todo-status').val(`讀檔失敗:${describeError(error)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```js back
|
|
155
|
+
import { resolve } from 'node:path';
|
|
156
|
+
|
|
157
|
+
function todoPath(input) {
|
|
158
|
+
const value = String(input ?? '').trim();
|
|
159
|
+
if (!value) throw new Error('請先輸入 JSON 檔案路徑');
|
|
160
|
+
return resolve(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateTodoObject(obj) {
|
|
164
|
+
if (!obj || typeof obj !== 'object' || !Array.isArray(obj.todos))
|
|
165
|
+
throw new Error('Todo JSON 必須是包含 todos 陣列的物件');
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
todos: obj.todos.map((item, index) => {
|
|
169
|
+
if (!item || typeof item !== 'object' || typeof item.value !== 'string')
|
|
170
|
+
throw new Error(`第 ${index + 1} 個 Todo 項目必須包含字串 value`);
|
|
171
|
+
return { value: item.value, checked: Boolean(item.checked) };
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function saveTodoList(input, obj) {
|
|
177
|
+
const path = todoPath(input);
|
|
178
|
+
const validated = validateTodoObject(obj);
|
|
179
|
+
await Bun.write(path, JSON.stringify(validated, null, 1) + '\n');
|
|
180
|
+
return { path };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function loadTodoList(input) {
|
|
184
|
+
const path = todoPath(input);
|
|
185
|
+
const file = Bun.file(path);
|
|
186
|
+
if (!await file.exists()) throw new Error(`找不到 Todo 檔案:${path}`);
|
|
187
|
+
const obj = validateTodoObject(JSON.parse(await file.text()));
|
|
188
|
+
return { path, obj };
|
|
189
|
+
}
|
|
92
190
|
```
|
package/demos/todo.md
CHANGED
|
@@ -18,10 +18,25 @@ Fly to the moon
|
|
|
18
18
|
Enter Todo text above, then choose Add or Remove. Remove deletes the first item whose text matches exactly.
|
|
19
19
|
|
|
20
20
|
- [Add Todo](javascript:addTodo())
|
|
21
|
+
- .
|
|
21
22
|
- [Remove Todo](javascript:removeTodo())
|
|
23
|
+
- .
|
|
22
24
|
- [Show Completed](javascript:showCompleted())
|
|
25
|
+
- .
|
|
23
26
|
- [Show Pending](javascript:showPending())
|
|
24
27
|
|
|
28
|
+
## Save / Load
|
|
29
|
+
|
|
30
|
+
```text#todo-file
|
|
31
|
+
todo_list.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Enter a JSON file path above. Saving writes human-readable JSON; loading replaces the current Todo list after confirmation.
|
|
35
|
+
|
|
36
|
+
- [Save Todos](javascript:saveTodos())
|
|
37
|
+
- .
|
|
38
|
+
- [Load Todos](javascript:loadTodos())
|
|
39
|
+
|
|
25
40
|
## Operation Result
|
|
26
41
|
|
|
27
42
|
```text#todo-status
|
|
@@ -52,6 +67,14 @@ function showItems(title, checked) {
|
|
|
52
67
|
$('#todo-status').val(`Found ${items.length} ${title.toLowerCase()} ${noun}`);
|
|
53
68
|
}
|
|
54
69
|
|
|
70
|
+
function todoFile() {
|
|
71
|
+
return $('#todo-file').val().trim();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function describeError(error) {
|
|
75
|
+
return error?.message || String(error);
|
|
76
|
+
}
|
|
77
|
+
|
|
55
78
|
export function addTodo() {
|
|
56
79
|
const value = todoText();
|
|
57
80
|
if (!value) {
|
|
@@ -90,4 +113,79 @@ export function showCompleted() {
|
|
|
90
113
|
export function showPending() {
|
|
91
114
|
showItems('Pending', false);
|
|
92
115
|
}
|
|
116
|
+
|
|
117
|
+
export async function saveTodos() {
|
|
118
|
+
const file = todoFile();
|
|
119
|
+
if (!file) {
|
|
120
|
+
$('#todo-status').val('Save failed: enter a JSON file path first');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const obj = { todos: $('#todos').slice() };
|
|
126
|
+
const result = await rpc.saveTodoList(file, obj);
|
|
127
|
+
$('#todo-status').val(`Saved ${obj.todos.length} Todos to ${result.path}`);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
$('#todo-status').val(`Save failed: ${describeError(error)}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function loadTodos() {
|
|
134
|
+
const file = todoFile();
|
|
135
|
+
if (!file) {
|
|
136
|
+
$('#todo-status').val('Load failed: enter a JSON file path first');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const result = await rpc.loadTodoList(file);
|
|
141
|
+
const json = JSON.stringify(result.obj, null, 1);
|
|
142
|
+
if (!confirm(`Replace the current Todo list with this JSON from “${file}”?\n\n${json}`)) {
|
|
143
|
+
$('#todo-status').val('Load cancelled; current Todos were not changed');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const current = $('#todos').slice();
|
|
147
|
+
$('#todos').splice(0, current.length, ...result.obj.todos);
|
|
148
|
+
$('#todo-status').val(`Loaded ${result.obj.todos.length} Todos from ${result.path}`);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
$('#todo-status').val(`Load failed: ${describeError(error)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```js back
|
|
156
|
+
import { resolve } from 'node:path';
|
|
157
|
+
|
|
158
|
+
function todoPath(input) {
|
|
159
|
+
const value = String(input ?? '').trim();
|
|
160
|
+
if (!value) throw new Error('Enter a JSON file path first');
|
|
161
|
+
return resolve(value);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function validateTodoObject(obj) {
|
|
165
|
+
if (!obj || typeof obj !== 'object' || !Array.isArray(obj.todos))
|
|
166
|
+
throw new Error('Todo JSON must be an object containing a todos array');
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
todos: obj.todos.map((item, index) => {
|
|
170
|
+
if (!item || typeof item !== 'object' || typeof item.value !== 'string')
|
|
171
|
+
throw new Error(`Todo item ${index + 1} must contain a string value`);
|
|
172
|
+
return { value: item.value, checked: Boolean(item.checked) };
|
|
173
|
+
}),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function saveTodoList(input, obj) {
|
|
178
|
+
const path = todoPath(input);
|
|
179
|
+
const validated = validateTodoObject(obj);
|
|
180
|
+
await Bun.write(path, JSON.stringify(validated, null, 1) + '\n');
|
|
181
|
+
return { path };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function loadTodoList(input) {
|
|
185
|
+
const path = todoPath(input);
|
|
186
|
+
const file = Bun.file(path);
|
|
187
|
+
if (!await file.exists()) throw new Error(`Todo file not found: ${path}`);
|
|
188
|
+
const obj = validateTodoObject(JSON.parse(await file.text()));
|
|
189
|
+
return { path, obj };
|
|
190
|
+
}
|
|
93
191
|
```
|
package/edit
ADDED