dsh-plugin-term-dictionary 1.1.0 → 1.1.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/CHANGELOG.md +34 -1
- package/lib/client.js +272 -8
- package/lib/core/copy.js +26 -0
- package/lib/core/dictionary.js +93 -3
- package/lib/core/hover.js +40 -0
- package/lib/core/store.js +23 -0
- package/lib/core/styles.js +2 -0
- package/lib/core/views.js +88 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
本文件记录**发布出去**的版本。每一项都是可验证的行为,不写计划。
|
|
4
4
|
|
|
5
|
-
## 1.1.
|
|
5
|
+
## 1.1.1 — 2026-10-11
|
|
6
|
+
|
|
7
|
+
### 组可以改名、移动、删除
|
|
8
|
+
|
|
9
|
+
- **重命名与移动是同一件事**,因为组就是一条路径:`backend → 服务端` 是改名,把 `net` 从 `backend` 挪到
|
|
10
|
+
`infra` 是移动。组行第二行有 `[✎]`(改名/移动)与 `[🗑]`(删除)。
|
|
11
|
+
- 每个词条都走**单条移动那条带时间戳的决定**(`groupAt`)——批量移动不是"决定"这条规则的例外,所以它
|
|
12
|
+
同样经得起与宿主的合并。
|
|
13
|
+
- **站在被改名的组里会跟着它走**(留在同一层、路径变了),而不是被扔回顶层。
|
|
14
|
+
- 改到已存在的组名 = **合并两个组**。三种情况明确拒绝:空名、名字没变、把组移到自己里面。
|
|
15
|
+
- **删除是软删除**:组里的词条进「已删除」并留下墓碑(可逐条撤回,也挡住路过的包把它们又放回来)。
|
|
16
|
+
确认框里**带条数**——"删除这个分组?"与"删除 12 条词条?"是两个不同的提议。
|
|
17
|
+
- 改名表单是**内联**的,不是 `prompt()`:Electron 不实现 `prompt`。
|
|
18
|
+
|
|
19
|
+
### 组卡片有一行正文
|
|
20
|
+
|
|
21
|
+
- 组行现在是 `[📁] 名字 N 条 ›` 加一行**里面最新的几个词条名**(超过三条接「等 N 条」)。空盒子变成了
|
|
22
|
+
可读的预览,等高也就有了内容可撑。
|
|
23
|
+
|
|
24
|
+
### 悬停/点击:段落左侧空白不再误触发
|
|
25
|
+
|
|
26
|
+
- 报告:术语在**行首**时,鼠标在它**正左侧**任意位置(直到聊天区边缘)都会弹出悬浮窗、点击也会进词条。
|
|
27
|
+
- 原因:`caretRangeFromPoint` **横向夹取**——左空白里的点被解析成该行起点(offset 0),而当术语正好在
|
|
28
|
+
行首时,offset 0 就是它。
|
|
29
|
+
- 修法:量**光标自己**的位置(折叠 Range 的 `getBoundingClientRect`),只有光标真的在该点时才接受
|
|
30
|
+
(容差 8px ≈ 词前空格宽度)。量不到光标时选择信任,而不是拒掉所有悬浮。
|
|
31
|
+
|
|
32
|
+
### 打包闸门不再崩在退出上(开发侧)
|
|
33
|
+
|
|
34
|
+
- `pnpm publish` 曾报 `ERR_PNPM_EXECUTOR_LIFECYCLE_SCRIPT_FAILED` / `0xc0000409`:闸门**通过了**,却在
|
|
35
|
+
`process.exit()` 时撞上 libuv 的 `UV_HANDLE_CLOSING` 断言(fetch 的 keep-alive socket)。现在先关
|
|
36
|
+
dispatcher、再用 `process.exitCode` 让事件循环自然排空;绿路径退 0、红路径退 1,两条都验过。
|
|
37
|
+
|
|
38
|
+
## 1.1.0 — 2026-10-10
|
|
6
39
|
|
|
7
40
|
### 词条组(目录)
|
|
8
41
|
|
package/lib/client.js
CHANGED
|
@@ -584,6 +584,19 @@ window.__ModuleLoader__.load({
|
|
|
584
584
|
groupGo: "回到「{name}」",
|
|
585
585
|
groupCount: "{count} 条",
|
|
586
586
|
groupEmpty: "这一层只有分组,还没有词条。",
|
|
587
|
+
groupSampleJoin: "、",
|
|
588
|
+
groupSampleMore: " 等 {count} 条",
|
|
589
|
+
groupRename: "重命名或移动",
|
|
590
|
+
groupRenameTo: "改到:",
|
|
591
|
+
groupRenameApply: "改名 / 移动",
|
|
592
|
+
groupDelete: "删除分组",
|
|
593
|
+
groupRenamed: "已移动 {count} 条词条。",
|
|
594
|
+
groupDeleted: "已删除 {count} 条词条,它们都在「已删除」里,可以撤回。",
|
|
595
|
+
groupDeleteConfirm: "删除分组「{name}」里的 {count} 条词条?它们会进入「已删除」,之后可以逐条撤回。",
|
|
596
|
+
"groupError_no-group": "那个分组不存在。",
|
|
597
|
+
"groupError_no-name": "分组名不能为空。",
|
|
598
|
+
"groupError_same": "新旧名字一样,没有需要动的地方。",
|
|
599
|
+
"groupError_into-itself": "不能把分组移动到它自己里面。",
|
|
587
600
|
groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
|
|
588
601
|
termPlaceholder: "例如 event sourcing",
|
|
589
602
|
glossPlaceholder: "用一两句话说明它的含义",
|
|
@@ -886,6 +899,19 @@ window.__ModuleLoader__.load({
|
|
|
886
899
|
groupGo: "Go back to {name}",
|
|
887
900
|
groupCount: "{count} entries",
|
|
888
901
|
groupEmpty: "This level holds only groups so far.",
|
|
902
|
+
groupSampleJoin: ", ",
|
|
903
|
+
groupSampleMore: " and {count} more",
|
|
904
|
+
groupRename: "Rename or move",
|
|
905
|
+
groupRenameTo: "Move to:",
|
|
906
|
+
groupRenameApply: "Rename / move",
|
|
907
|
+
groupDelete: "Delete group",
|
|
908
|
+
groupRenamed: "Moved {count} entries.",
|
|
909
|
+
groupDeleted: "Deleted {count} entries. They are on the deleted list and can be restored one by one.",
|
|
910
|
+
groupDeleteConfirm: "Delete the {count} entries in “{name}”? They go to the deleted list, where each can be restored.",
|
|
911
|
+
"groupError_no-group": "There is no such group.",
|
|
912
|
+
"groupError_no-name": "A group needs a name.",
|
|
913
|
+
"groupError_same": "The name is unchanged, so there is nothing to move.",
|
|
914
|
+
"groupError_into-itself": "A group cannot be moved inside itself.",
|
|
889
915
|
groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
|
|
890
916
|
termPlaceholder: "e.g. event sourcing",
|
|
891
917
|
glossPlaceholder: "Say what it means in one or two sentences",
|
|
@@ -2388,6 +2414,73 @@ window.__ModuleLoader__.load({
|
|
|
2388
2414
|
return { total: live.length, explained, unexplained: live.length - explained, pinned };
|
|
2389
2415
|
}
|
|
2390
2416
|
|
|
2417
|
+
/**
|
|
2418
|
+
* Move a group — and everything under it — to another path.
|
|
2419
|
+
*
|
|
2420
|
+
* Renaming and moving are the same act, because a group IS a path: renaming `backend` to `服务端` and moving
|
|
2421
|
+
* `net` out of `backend` into `infra` are both "these entries live somewhere else now". One primitive, two
|
|
2422
|
+
* names in the UI, so the two cannot drift apart.
|
|
2423
|
+
*
|
|
2424
|
+
* Each entry is moved through {@link editEntry}, so each carries the stamped decision (`groupAt`) a merge
|
|
2425
|
+
* weighs — a bulk move is not an exception to "a move is a decision".
|
|
2426
|
+
*
|
|
2427
|
+
* Refused: an empty target, a target inside the group being moved (which would put it inside itself), and a
|
|
2428
|
+
* target that leaves everything where it is. Renaming ONTO an existing group is allowed and merges the two,
|
|
2429
|
+
* which is what a reader asking for it means.
|
|
2430
|
+
*
|
|
2431
|
+
* @param state - the current document.
|
|
2432
|
+
* @param from - the path to move.
|
|
2433
|
+
* @param to - the path to move it to.
|
|
2434
|
+
* @param options - `now` overrides the timestamps.
|
|
2435
|
+
* @returns `{ state, moved, error }`.
|
|
2436
|
+
*/
|
|
2437
|
+
function renameGroup(state, from, to, options) {
|
|
2438
|
+
const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
|
|
2439
|
+
const target = typeof to === "string" ? to.replace(/^\/+|\/+$/g, "") : "";
|
|
2440
|
+
if (source === "") return { state, moved: 0, error: "no-group" };
|
|
2441
|
+
if (target === "") return { state, moved: 0, error: "no-name" };
|
|
2442
|
+
if (target === source) return { state, moved: 0, error: "same" };
|
|
2443
|
+
if (target.startsWith(`${source}/`)) return { state, moved: 0, error: "into-itself" };
|
|
2444
|
+
const now = options?.now ?? Date.now();
|
|
2445
|
+
let next = state;
|
|
2446
|
+
let moved = 0;
|
|
2447
|
+
let stamp = now;
|
|
2448
|
+
for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
|
|
2449
|
+
stamp += 1;
|
|
2450
|
+
const rest = entry.group === source ? "" : entry.group.slice(source.length + 1);
|
|
2451
|
+
next = editEntry(next, entry.term, { group: rest === "" ? target : `${target}/${rest}` }, { now: stamp }).state;
|
|
2452
|
+
moved += 1;
|
|
2453
|
+
}
|
|
2454
|
+
return { state: next, moved, error: null };
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/**
|
|
2458
|
+
* Delete a group: every entry under it goes to the deleted list.
|
|
2459
|
+
*
|
|
2460
|
+
* Soft, like every other deletion here — the tombstones make it reversible from 「已删除」, and they are also
|
|
2461
|
+
* what stops a passing pack from putting the terms straight back. A group therefore disappears when the last
|
|
2462
|
+
* entry in it does, which is the same rule that made it appear.
|
|
2463
|
+
*
|
|
2464
|
+
* @param state - the current document.
|
|
2465
|
+
* @param from - the path to empty.
|
|
2466
|
+
* @param options - `now` overrides the timestamps.
|
|
2467
|
+
* @returns `{ state, removed, error }`.
|
|
2468
|
+
*/
|
|
2469
|
+
function deleteGroup(state, from, options) {
|
|
2470
|
+
const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
|
|
2471
|
+
if (source === "") return { state, removed: 0, error: "no-group" };
|
|
2472
|
+
const now = options?.now ?? Date.now();
|
|
2473
|
+
let next = state;
|
|
2474
|
+
let removed = 0;
|
|
2475
|
+
let stamp = now;
|
|
2476
|
+
for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
|
|
2477
|
+
stamp += 1;
|
|
2478
|
+
next = removeEntry(next, entry.id, { now: stamp }).state;
|
|
2479
|
+
removed += 1;
|
|
2480
|
+
}
|
|
2481
|
+
return { state: next, removed, error: null };
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2391
2484
|
/**
|
|
2392
2485
|
* The groups immediately inside one path, with how many entries each holds.
|
|
2393
2486
|
*
|
|
@@ -2395,14 +2488,32 @@ window.__ModuleLoader__.load({
|
|
|
2395
2488
|
* that child, however deep. A group exists as long as something is in it — there is no separate list of
|
|
2396
2489
|
* empty groups to keep in step with the entries, which is the trade this makes deliberately.
|
|
2397
2490
|
*
|
|
2491
|
+
* Each group also carries a SAMPLE of the terms inside it. A folder row with only a name and a count is a
|
|
2492
|
+
* row of empty space next to entries full of text — and a preview answers the question the row raises
|
|
2493
|
+
* without making the reader open it to find out.
|
|
2494
|
+
*
|
|
2398
2495
|
* @param entries - every live entry.
|
|
2399
2496
|
* @param prefix - the path to look inside, `""` for the top level.
|
|
2400
|
-
* @returns `[{ group, name, count }]`, alphabetical.
|
|
2497
|
+
* @returns `[{ group, name, count, sample }]`, alphabetical.
|
|
2401
2498
|
*/
|
|
2499
|
+
/**
|
|
2500
|
+
* How many terms a group's preview names before it stops.
|
|
2501
|
+
*
|
|
2502
|
+
* Three: enough to say what kind of thing is in there, few enough that the line stays one line in a
|
|
2503
|
+
* multi-column layout.
|
|
2504
|
+
*/
|
|
2505
|
+
const GROUP_SAMPLE = 3;
|
|
2506
|
+
|
|
2402
2507
|
function groupsIn(entries, prefix) {
|
|
2403
2508
|
const base = prefix === undefined || prefix === null ? "" : String(prefix);
|
|
2404
2509
|
const counts = new Map();
|
|
2405
|
-
|
|
2510
|
+
const samples = new Map();
|
|
2511
|
+
// Newest first, the order the list itself uses, so the preview is the top of what you would see inside.
|
|
2512
|
+
const ordered = (Array.isArray(entries) ? entries : [])
|
|
2513
|
+
.filter((entry) => (entry?.deletedAt ?? 0) === 0 && typeof entry?.term === "string" && entry.term !== "")
|
|
2514
|
+
.slice()
|
|
2515
|
+
.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
|
|
2516
|
+
for (const entry of ordered) {
|
|
2406
2517
|
if ((entry?.deletedAt ?? 0) !== 0) continue;
|
|
2407
2518
|
const group = typeof entry?.group === "string" ? entry.group : "";
|
|
2408
2519
|
if (group === "" || group === base) continue;
|
|
@@ -2412,9 +2523,12 @@ window.__ModuleLoader__.load({
|
|
|
2412
2523
|
if (inside === null || inside === "") continue;
|
|
2413
2524
|
const name = inside.split("/")[0];
|
|
2414
2525
|
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
2526
|
+
const sample = samples.get(name) ?? [];
|
|
2527
|
+
if (sample.length < GROUP_SAMPLE) sample.push(entry.term);
|
|
2528
|
+
samples.set(name, sample);
|
|
2415
2529
|
}
|
|
2416
2530
|
return [...counts.entries()]
|
|
2417
|
-
.map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count }))
|
|
2531
|
+
.map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count, sample: samples.get(name) ?? [] }))
|
|
2418
2532
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
2419
2533
|
}
|
|
2420
2534
|
|
|
@@ -2593,6 +2707,8 @@ window.__ModuleLoader__.load({
|
|
|
2593
2707
|
upsertEntry,
|
|
2594
2708
|
editEntry,
|
|
2595
2709
|
restoreEntry,
|
|
2710
|
+
renameGroup,
|
|
2711
|
+
deleteGroup,
|
|
2596
2712
|
recordSighting,
|
|
2597
2713
|
removeEntry,
|
|
2598
2714
|
reviveEntry,
|
|
@@ -4496,6 +4612,29 @@ window.__ModuleLoader__.load({
|
|
|
4496
4612
|
return result.entry;
|
|
4497
4613
|
},
|
|
4498
4614
|
|
|
4615
|
+
/**
|
|
4616
|
+
* Move a group, and everything under it, to another path.
|
|
4617
|
+
* @param from - the path to move.
|
|
4618
|
+
* @param to - the path to move it to.
|
|
4619
|
+
* @returns a promise resolving to `{ moved, error }`.
|
|
4620
|
+
*/
|
|
4621
|
+
async renameGroup(from, to) {
|
|
4622
|
+
const result = dictionary.renameGroup(state, from, to, { now: now() });
|
|
4623
|
+
if (result.moved > 0) await commit(result.state);
|
|
4624
|
+
return { moved: result.moved, error: result.error };
|
|
4625
|
+
},
|
|
4626
|
+
|
|
4627
|
+
/**
|
|
4628
|
+
* Delete a group by deleting everything inside it, which is the only way a group can be deleted.
|
|
4629
|
+
* @param from - the path to empty.
|
|
4630
|
+
* @returns a promise resolving to `{ removed, error }`.
|
|
4631
|
+
*/
|
|
4632
|
+
async deleteGroup(from) {
|
|
4633
|
+
const result = dictionary.deleteGroup(state, from, { now: now() });
|
|
4634
|
+
if (result.removed > 0) await commit(result.state);
|
|
4635
|
+
return { removed: result.removed, error: result.error };
|
|
4636
|
+
},
|
|
4637
|
+
|
|
4499
4638
|
/**
|
|
4500
4639
|
* Bring a deleted term back, as an explicit act.
|
|
4501
4640
|
*
|
|
@@ -8401,6 +8540,45 @@ window.__ModuleLoader__.load({
|
|
|
8401
8540
|
return null;
|
|
8402
8541
|
}
|
|
8403
8542
|
|
|
8543
|
+
/**
|
|
8544
|
+
* How far left of the caret a pointer may still count as being on the text.
|
|
8545
|
+
*
|
|
8546
|
+
* The space before a word is enough: landing in it resolves the caret to the word's own start, and a
|
|
8547
|
+
* reader aiming at the first letter often lands there. A paragraph's left margin is tens of pixels, which
|
|
8548
|
+
* is what this has to reject.
|
|
8549
|
+
*/
|
|
8550
|
+
const LEFT_TOLERANCE_PX = 8;
|
|
8551
|
+
|
|
8552
|
+
/**
|
|
8553
|
+
* Whether the caret a point resolved to actually sits at that point.
|
|
8554
|
+
*
|
|
8555
|
+
* `caretRangeFromPoint` CLAMPS horizontally: a point in the left margin of a line resolves to the line's
|
|
8556
|
+
* start, offset 0. When a term begins that line, offset 0 IS that term — so every point from the panel's
|
|
8557
|
+
* left edge to the first character read as "on the term", and the card opened from anywhere in the margin,
|
|
8558
|
+
* with a click to match. That is how this was reported.
|
|
8559
|
+
*
|
|
8560
|
+
* The caret's own rectangle is the live proof of where the text is: it is measured from the document at
|
|
8561
|
+
* this instant, so unlike a block snapshot it cannot go stale. One measurement per event, and only once a
|
|
8562
|
+
* caret has been found at all.
|
|
8563
|
+
*
|
|
8564
|
+
* @param x - viewport x of the pointer.
|
|
8565
|
+
* @param caret - the Range the point resolved to.
|
|
8566
|
+
* @returns true when the caret is at the pointer rather than clamped to the start of a line.
|
|
8567
|
+
*/
|
|
8568
|
+
function caretIsAt(x, caret) {
|
|
8569
|
+
let rect = null;
|
|
8570
|
+
try {
|
|
8571
|
+
rect = typeof caret?.getBoundingClientRect === "function" ? caret.getBoundingClientRect() : null;
|
|
8572
|
+
} catch (error) {
|
|
8573
|
+
rect = null;
|
|
8574
|
+
}
|
|
8575
|
+
// A browser (or a fixture) that cannot answer is trusted: refusing every hover because one optional
|
|
8576
|
+
// measurement is missing would be a worse failure than the one being fixed. An all-zero rectangle says
|
|
8577
|
+
// the same thing in another shape — a detached or not-yet-laid-out range.
|
|
8578
|
+
if (rect === null || (rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0)) return true;
|
|
8579
|
+
return rect.left <= x + LEFT_TOLERANCE_PX;
|
|
8580
|
+
}
|
|
8581
|
+
|
|
8404
8582
|
/**
|
|
8405
8583
|
* The marked occurrence under a point.
|
|
8406
8584
|
*
|
|
@@ -8415,6 +8593,7 @@ window.__ModuleLoader__.load({
|
|
|
8415
8593
|
function matchAt(x, y) {
|
|
8416
8594
|
const caret = caretAt(x, y);
|
|
8417
8595
|
if (caret === null) return null;
|
|
8596
|
+
if (!caretIsAt(x, caret)) return null;
|
|
8418
8597
|
const at = resolveCaret(caret, doc);
|
|
8419
8598
|
if (at === null) return null;
|
|
8420
8599
|
let best = null;
|
|
@@ -9175,6 +9354,8 @@ window.__ModuleLoader__.load({
|
|
|
9175
9354
|
/** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
|
|
9176
9355
|
groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
|
|
9177
9356
|
groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
|
|
9357
|
+
/** One line saying what is inside a group, so the card is not a name over a blank space. */
|
|
9358
|
+
groupPreview: { margin: 0, fontSize: "12px", lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
9178
9359
|
/**
|
|
9179
9360
|
* The row's action toolbar: the second line, always.
|
|
9180
9361
|
*
|
|
@@ -11243,6 +11424,13 @@ window.__ModuleLoader__.load({
|
|
|
11243
11424
|
* because you looked at it — so it is panel state, next to the search box and the tab.
|
|
11244
11425
|
*/
|
|
11245
11426
|
const [group, setGroup] = React.useState("");
|
|
11427
|
+
/**
|
|
11428
|
+
* The group being renamed or moved, as `{ group, value }`, or null.
|
|
11429
|
+
*
|
|
11430
|
+
* An inline form rather than `prompt()`: Electron does not implement `prompt`, so a rename dialog
|
|
11431
|
+
* built on it would do nothing at all in the app this plugin runs in.
|
|
11432
|
+
*/
|
|
11433
|
+
const [renaming, setRenaming] = React.useState(null);
|
|
11246
11434
|
const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
|
|
11247
11435
|
/**
|
|
11248
11436
|
* The groups directly inside the current one, each with how many entries it holds.
|
|
@@ -11285,6 +11473,43 @@ window.__ModuleLoader__.load({
|
|
|
11285
11473
|
return moved;
|
|
11286
11474
|
};
|
|
11287
11475
|
|
|
11476
|
+
/**
|
|
11477
|
+
* Rename or move a group, and follow it if the panel was standing inside it.
|
|
11478
|
+
*
|
|
11479
|
+
* Following is the point: a reader who renames the folder they are in must still be looking at it
|
|
11480
|
+
* afterwards, not dropped back at the top wondering where it went.
|
|
11481
|
+
*/
|
|
11482
|
+
const commitRename = async () => {
|
|
11483
|
+
if (renaming === null) return;
|
|
11484
|
+
const result = await store.renameGroup(renaming.group, renaming.value);
|
|
11485
|
+
if (result.error !== null) {
|
|
11486
|
+
props.setToast?.(t(`groupError_${result.error}`));
|
|
11487
|
+
return;
|
|
11488
|
+
}
|
|
11489
|
+
const target = renaming.value.replace(/^\/+|\/+$/g, "");
|
|
11490
|
+
if (group === renaming.group || group.startsWith(`${renaming.group}/`)) {
|
|
11491
|
+
setGroup(group === renaming.group ? target : `${target}${group.slice(renaming.group.length)}`);
|
|
11492
|
+
}
|
|
11493
|
+
setRenaming(null);
|
|
11494
|
+
props.setToast?.(t("groupRenamed", { count: String(result.moved) }));
|
|
11495
|
+
};
|
|
11496
|
+
|
|
11497
|
+
/**
|
|
11498
|
+
* Delete a group by deleting what is in it, after saying how much that is.
|
|
11499
|
+
*
|
|
11500
|
+
* The entries go to 「已删除」 rather than away, so this is reversible — and the count is in the
|
|
11501
|
+
* question, because "delete this group?" and "delete 12 entries?" are different offers.
|
|
11502
|
+
*/
|
|
11503
|
+
const removeGroup = async (sub) => {
|
|
11504
|
+
if (globalThis.confirm?.(t("groupDeleteConfirm", { name: sub.name, count: String(sub.count) })) !== true) return;
|
|
11505
|
+
const result = await store.deleteGroup(sub.group);
|
|
11506
|
+
if (result.error !== null) {
|
|
11507
|
+
props.setToast?.(t(`groupError_${result.error}`));
|
|
11508
|
+
return;
|
|
11509
|
+
}
|
|
11510
|
+
props.setToast?.(t("groupDeleted", { count: String(result.removed) }));
|
|
11511
|
+
};
|
|
11512
|
+
|
|
11288
11513
|
/** Open the editor for a blank entry, prefilled from an optional term. */
|
|
11289
11514
|
const startCreate = (term = "", context = "") => {
|
|
11290
11515
|
setEditing({
|
|
@@ -11818,6 +12043,24 @@ window.__ModuleLoader__.load({
|
|
|
11818
12043
|
// row. A breadcrumb says where you are and is the way back up, because a directory you can only
|
|
11819
12044
|
// enter is a trap.
|
|
11820
12045
|
[
|
|
12046
|
+
renaming === null ? null : h(
|
|
12047
|
+
"div",
|
|
12048
|
+
{ style: { ...applyStyles.transferRow, gridColumn: "1 / -1" }, "data-term-group-form": renaming.group },
|
|
12049
|
+
h("span", { style: applyStyles.fieldLabel }, t("groupRenameTo")),
|
|
12050
|
+
h("input", {
|
|
12051
|
+
type: "text",
|
|
12052
|
+
value: renaming.value,
|
|
12053
|
+
"aria-label": t("groupRename"),
|
|
12054
|
+
onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
|
|
12055
|
+
onKeyDown: (event) => {
|
|
12056
|
+
if (event.key === "Enter") void commitRename();
|
|
12057
|
+
if (event.key === "Escape") setRenaming(null);
|
|
12058
|
+
},
|
|
12059
|
+
style: { ...applyStyles.input, maxWidth: "18em" }
|
|
12060
|
+
}),
|
|
12061
|
+
h(Button, { label: t("groupRenameApply"), onClick: () => void commitRename() }, t("groupRenameApply")),
|
|
12062
|
+
h(Button, { label: t("cancel"), onClick: () => setRenaming(null) }, t("cancel"))
|
|
12063
|
+
),
|
|
11821
12064
|
group === "" ? null : h(
|
|
11822
12065
|
"div",
|
|
11823
12066
|
{ style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
|
|
@@ -11847,16 +12090,19 @@ window.__ModuleLoader__.load({
|
|
|
11847
12090
|
"article",
|
|
11848
12091
|
{
|
|
11849
12092
|
key: `group:${sub.group}`,
|
|
11850
|
-
//
|
|
11851
|
-
//
|
|
11852
|
-
|
|
11853
|
-
style: { ...applyStyles.row, alignSelf: "start" },
|
|
12093
|
+
// It fills its row again, now that the height carries something: with the preview below, the
|
|
12094
|
+
// space that used to be emptiness is the first thing you would see on opening it.
|
|
12095
|
+
style: applyStyles.row,
|
|
11854
12096
|
"data-term-group": sub.group,
|
|
11855
12097
|
// The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
|
|
11856
12098
|
// Only the label being live is what "the group cannot be clicked into" meant: a reader
|
|
11857
12099
|
// clicks the row, the same gesture that works one line below, and nothing happened.
|
|
11858
12100
|
// The label stays a button so it is reachable by keyboard and named for a screen reader.
|
|
11859
|
-
onClick: () =>
|
|
12101
|
+
onClick: (event) => {
|
|
12102
|
+
// The buttons inside keep their own meaning, exactly as they do on an entry row.
|
|
12103
|
+
if (typeof event?.target?.closest === "function" && event.target.closest("button") !== null) return;
|
|
12104
|
+
setGroup(sub.group);
|
|
12105
|
+
}
|
|
11860
12106
|
},
|
|
11861
12107
|
h("div", { style: applyStyles.rowHead },
|
|
11862
12108
|
h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
|
|
@@ -11869,6 +12115,24 @@ window.__ModuleLoader__.load({
|
|
|
11869
12115
|
h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
|
|
11870
12116
|
h("span", { style: applyStyles.spacer }),
|
|
11871
12117
|
h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
|
|
12118
|
+
),
|
|
12119
|
+
// The same shape an entry row has: a title line, a line of actions, then the body.
|
|
12120
|
+
h(
|
|
12121
|
+
"div",
|
|
12122
|
+
{ style: applyStyles.rowActions, "data-term-group-actions": sub.group },
|
|
12123
|
+
h(IconButton, { title: t("groupRename"), onClick: () => setRenaming({ group: sub.group, value: sub.group }) }, Icons.edit(13)),
|
|
12124
|
+
h(IconButton, { title: t("groupDelete"), onClick: () => void removeGroup(sub) }, Icons.trash(13))
|
|
12125
|
+
),
|
|
12126
|
+
// What is inside, in one line: the row stops being a name over a blank card.
|
|
12127
|
+
h(
|
|
12128
|
+
"p",
|
|
12129
|
+
{ style: applyStyles.groupPreview, "data-term-group-sample": sub.group },
|
|
12130
|
+
[
|
|
12131
|
+
sub.sample.join(t("groupSampleJoin")),
|
|
12132
|
+
sub.count > sub.sample.length ? t("groupSampleMore", { count: String(sub.count - sub.sample.length) }) : ""
|
|
12133
|
+
]
|
|
12134
|
+
.filter((part) => part !== "")
|
|
12135
|
+
.join("")
|
|
11872
12136
|
)
|
|
11873
12137
|
)
|
|
11874
12138
|
),
|
package/lib/core/copy.js
CHANGED
|
@@ -238,6 +238,19 @@ const zh = {
|
|
|
238
238
|
groupGo: "回到「{name}」",
|
|
239
239
|
groupCount: "{count} 条",
|
|
240
240
|
groupEmpty: "这一层只有分组,还没有词条。",
|
|
241
|
+
groupSampleJoin: "、",
|
|
242
|
+
groupSampleMore: " 等 {count} 条",
|
|
243
|
+
groupRename: "重命名或移动",
|
|
244
|
+
groupRenameTo: "改到:",
|
|
245
|
+
groupRenameApply: "改名 / 移动",
|
|
246
|
+
groupDelete: "删除分组",
|
|
247
|
+
groupRenamed: "已移动 {count} 条词条。",
|
|
248
|
+
groupDeleted: "已删除 {count} 条词条,它们都在「已删除」里,可以撤回。",
|
|
249
|
+
groupDeleteConfirm: "删除分组「{name}」里的 {count} 条词条?它们会进入「已删除」,之后可以逐条撤回。",
|
|
250
|
+
"groupError_no-group": "那个分组不存在。",
|
|
251
|
+
"groupError_no-name": "分组名不能为空。",
|
|
252
|
+
"groupError_same": "新旧名字一样,没有需要动的地方。",
|
|
253
|
+
"groupError_into-itself": "不能把分组移动到它自己里面。",
|
|
241
254
|
groupPlaceholder: "留空 = 顶层;用 / 分层,例如 backend/net",
|
|
242
255
|
termPlaceholder: "例如 event sourcing",
|
|
243
256
|
glossPlaceholder: "用一两句话说明它的含义",
|
|
@@ -540,6 +553,19 @@ const en = {
|
|
|
540
553
|
groupGo: "Go back to {name}",
|
|
541
554
|
groupCount: "{count} entries",
|
|
542
555
|
groupEmpty: "This level holds only groups so far.",
|
|
556
|
+
groupSampleJoin: ", ",
|
|
557
|
+
groupSampleMore: " and {count} more",
|
|
558
|
+
groupRename: "Rename or move",
|
|
559
|
+
groupRenameTo: "Move to:",
|
|
560
|
+
groupRenameApply: "Rename / move",
|
|
561
|
+
groupDelete: "Delete group",
|
|
562
|
+
groupRenamed: "Moved {count} entries.",
|
|
563
|
+
groupDeleted: "Deleted {count} entries. They are on the deleted list and can be restored one by one.",
|
|
564
|
+
groupDeleteConfirm: "Delete the {count} entries in “{name}”? They go to the deleted list, where each can be restored.",
|
|
565
|
+
"groupError_no-group": "There is no such group.",
|
|
566
|
+
"groupError_no-name": "A group needs a name.",
|
|
567
|
+
"groupError_same": "The name is unchanged, so there is nothing to move.",
|
|
568
|
+
"groupError_into-itself": "A group cannot be moved inside itself.",
|
|
543
569
|
groupPlaceholder: "empty = top level; use / to nest, e.g. backend/net",
|
|
544
570
|
termPlaceholder: "e.g. event sourcing",
|
|
545
571
|
glossPlaceholder: "Say what it means in one or two sentences",
|
package/lib/core/dictionary.js
CHANGED
|
@@ -966,6 +966,73 @@ function summarize(state) {
|
|
|
966
966
|
return { total: live.length, explained, unexplained: live.length - explained, pinned };
|
|
967
967
|
}
|
|
968
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Move a group — and everything under it — to another path.
|
|
971
|
+
*
|
|
972
|
+
* Renaming and moving are the same act, because a group IS a path: renaming `backend` to `服务端` and moving
|
|
973
|
+
* `net` out of `backend` into `infra` are both "these entries live somewhere else now". One primitive, two
|
|
974
|
+
* names in the UI, so the two cannot drift apart.
|
|
975
|
+
*
|
|
976
|
+
* Each entry is moved through {@link editEntry}, so each carries the stamped decision (`groupAt`) a merge
|
|
977
|
+
* weighs — a bulk move is not an exception to "a move is a decision".
|
|
978
|
+
*
|
|
979
|
+
* Refused: an empty target, a target inside the group being moved (which would put it inside itself), and a
|
|
980
|
+
* target that leaves everything where it is. Renaming ONTO an existing group is allowed and merges the two,
|
|
981
|
+
* which is what a reader asking for it means.
|
|
982
|
+
*
|
|
983
|
+
* @param state - the current document.
|
|
984
|
+
* @param from - the path to move.
|
|
985
|
+
* @param to - the path to move it to.
|
|
986
|
+
* @param options - `now` overrides the timestamps.
|
|
987
|
+
* @returns `{ state, moved, error }`.
|
|
988
|
+
*/
|
|
989
|
+
function renameGroup(state, from, to, options) {
|
|
990
|
+
const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
|
|
991
|
+
const target = typeof to === "string" ? to.replace(/^\/+|\/+$/g, "") : "";
|
|
992
|
+
if (source === "") return { state, moved: 0, error: "no-group" };
|
|
993
|
+
if (target === "") return { state, moved: 0, error: "no-name" };
|
|
994
|
+
if (target === source) return { state, moved: 0, error: "same" };
|
|
995
|
+
if (target.startsWith(`${source}/`)) return { state, moved: 0, error: "into-itself" };
|
|
996
|
+
const now = options?.now ?? Date.now();
|
|
997
|
+
let next = state;
|
|
998
|
+
let moved = 0;
|
|
999
|
+
let stamp = now;
|
|
1000
|
+
for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
|
|
1001
|
+
stamp += 1;
|
|
1002
|
+
const rest = entry.group === source ? "" : entry.group.slice(source.length + 1);
|
|
1003
|
+
next = editEntry(next, entry.term, { group: rest === "" ? target : `${target}/${rest}` }, { now: stamp }).state;
|
|
1004
|
+
moved += 1;
|
|
1005
|
+
}
|
|
1006
|
+
return { state: next, moved, error: null };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Delete a group: every entry under it goes to the deleted list.
|
|
1011
|
+
*
|
|
1012
|
+
* Soft, like every other deletion here — the tombstones make it reversible from 「已删除」, and they are also
|
|
1013
|
+
* what stops a passing pack from putting the terms straight back. A group therefore disappears when the last
|
|
1014
|
+
* entry in it does, which is the same rule that made it appear.
|
|
1015
|
+
*
|
|
1016
|
+
* @param state - the current document.
|
|
1017
|
+
* @param from - the path to empty.
|
|
1018
|
+
* @param options - `now` overrides the timestamps.
|
|
1019
|
+
* @returns `{ state, removed, error }`.
|
|
1020
|
+
*/
|
|
1021
|
+
function deleteGroup(state, from, options) {
|
|
1022
|
+
const source = typeof from === "string" ? from.replace(/^\/+|\/+$/g, "") : "";
|
|
1023
|
+
if (source === "") return { state, removed: 0, error: "no-group" };
|
|
1024
|
+
const now = options?.now ?? Date.now();
|
|
1025
|
+
let next = state;
|
|
1026
|
+
let removed = 0;
|
|
1027
|
+
let stamp = now;
|
|
1028
|
+
for (const entry of state.entries.filter((each) => each.deletedAt === 0 && (each.group === source || each.group.startsWith(`${source}/`)))) {
|
|
1029
|
+
stamp += 1;
|
|
1030
|
+
next = removeEntry(next, entry.id, { now: stamp }).state;
|
|
1031
|
+
removed += 1;
|
|
1032
|
+
}
|
|
1033
|
+
return { state: next, removed, error: null };
|
|
1034
|
+
}
|
|
1035
|
+
|
|
969
1036
|
/**
|
|
970
1037
|
* The groups immediately inside one path, with how many entries each holds.
|
|
971
1038
|
*
|
|
@@ -973,14 +1040,32 @@ function summarize(state) {
|
|
|
973
1040
|
* that child, however deep. A group exists as long as something is in it — there is no separate list of
|
|
974
1041
|
* empty groups to keep in step with the entries, which is the trade this makes deliberately.
|
|
975
1042
|
*
|
|
1043
|
+
* Each group also carries a SAMPLE of the terms inside it. A folder row with only a name and a count is a
|
|
1044
|
+
* row of empty space next to entries full of text — and a preview answers the question the row raises
|
|
1045
|
+
* without making the reader open it to find out.
|
|
1046
|
+
*
|
|
976
1047
|
* @param entries - every live entry.
|
|
977
1048
|
* @param prefix - the path to look inside, `""` for the top level.
|
|
978
|
-
* @returns `[{ group, name, count }]`, alphabetical.
|
|
1049
|
+
* @returns `[{ group, name, count, sample }]`, alphabetical.
|
|
1050
|
+
*/
|
|
1051
|
+
/**
|
|
1052
|
+
* How many terms a group's preview names before it stops.
|
|
1053
|
+
*
|
|
1054
|
+
* Three: enough to say what kind of thing is in there, few enough that the line stays one line in a
|
|
1055
|
+
* multi-column layout.
|
|
979
1056
|
*/
|
|
1057
|
+
const GROUP_SAMPLE = 3;
|
|
1058
|
+
|
|
980
1059
|
function groupsIn(entries, prefix) {
|
|
981
1060
|
const base = prefix === undefined || prefix === null ? "" : String(prefix);
|
|
982
1061
|
const counts = new Map();
|
|
983
|
-
|
|
1062
|
+
const samples = new Map();
|
|
1063
|
+
// Newest first, the order the list itself uses, so the preview is the top of what you would see inside.
|
|
1064
|
+
const ordered = (Array.isArray(entries) ? entries : [])
|
|
1065
|
+
.filter((entry) => (entry?.deletedAt ?? 0) === 0 && typeof entry?.term === "string" && entry.term !== "")
|
|
1066
|
+
.slice()
|
|
1067
|
+
.sort((left, right) => (right.createdAt ?? 0) - (left.createdAt ?? 0) || left.term.localeCompare(right.term));
|
|
1068
|
+
for (const entry of ordered) {
|
|
984
1069
|
if ((entry?.deletedAt ?? 0) !== 0) continue;
|
|
985
1070
|
const group = typeof entry?.group === "string" ? entry.group : "";
|
|
986
1071
|
if (group === "" || group === base) continue;
|
|
@@ -990,9 +1075,12 @@ function groupsIn(entries, prefix) {
|
|
|
990
1075
|
if (inside === null || inside === "") continue;
|
|
991
1076
|
const name = inside.split("/")[0];
|
|
992
1077
|
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
1078
|
+
const sample = samples.get(name) ?? [];
|
|
1079
|
+
if (sample.length < GROUP_SAMPLE) sample.push(entry.term);
|
|
1080
|
+
samples.set(name, sample);
|
|
993
1081
|
}
|
|
994
1082
|
return [...counts.entries()]
|
|
995
|
-
.map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count }))
|
|
1083
|
+
.map(([name, count]) => ({ group: base === "" ? name : `${base}/${name}`, name, count, sample: samples.get(name) ?? [] }))
|
|
996
1084
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
997
1085
|
}
|
|
998
1086
|
|
|
@@ -1171,6 +1259,8 @@ module.exports = {
|
|
|
1171
1259
|
upsertEntry,
|
|
1172
1260
|
editEntry,
|
|
1173
1261
|
restoreEntry,
|
|
1262
|
+
renameGroup,
|
|
1263
|
+
deleteGroup,
|
|
1174
1264
|
recordSighting,
|
|
1175
1265
|
removeEntry,
|
|
1176
1266
|
reviveEntry,
|
package/lib/core/hover.js
CHANGED
|
@@ -199,6 +199,45 @@ function createHoverLayer(options) {
|
|
|
199
199
|
return null;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* How far left of the caret a pointer may still count as being on the text.
|
|
204
|
+
*
|
|
205
|
+
* The space before a word is enough: landing in it resolves the caret to the word's own start, and a
|
|
206
|
+
* reader aiming at the first letter often lands there. A paragraph's left margin is tens of pixels, which
|
|
207
|
+
* is what this has to reject.
|
|
208
|
+
*/
|
|
209
|
+
const LEFT_TOLERANCE_PX = 8;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Whether the caret a point resolved to actually sits at that point.
|
|
213
|
+
*
|
|
214
|
+
* `caretRangeFromPoint` CLAMPS horizontally: a point in the left margin of a line resolves to the line's
|
|
215
|
+
* start, offset 0. When a term begins that line, offset 0 IS that term — so every point from the panel's
|
|
216
|
+
* left edge to the first character read as "on the term", and the card opened from anywhere in the margin,
|
|
217
|
+
* with a click to match. That is how this was reported.
|
|
218
|
+
*
|
|
219
|
+
* The caret's own rectangle is the live proof of where the text is: it is measured from the document at
|
|
220
|
+
* this instant, so unlike a block snapshot it cannot go stale. One measurement per event, and only once a
|
|
221
|
+
* caret has been found at all.
|
|
222
|
+
*
|
|
223
|
+
* @param x - viewport x of the pointer.
|
|
224
|
+
* @param caret - the Range the point resolved to.
|
|
225
|
+
* @returns true when the caret is at the pointer rather than clamped to the start of a line.
|
|
226
|
+
*/
|
|
227
|
+
function caretIsAt(x, caret) {
|
|
228
|
+
let rect = null;
|
|
229
|
+
try {
|
|
230
|
+
rect = typeof caret?.getBoundingClientRect === "function" ? caret.getBoundingClientRect() : null;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
rect = null;
|
|
233
|
+
}
|
|
234
|
+
// A browser (or a fixture) that cannot answer is trusted: refusing every hover because one optional
|
|
235
|
+
// measurement is missing would be a worse failure than the one being fixed. An all-zero rectangle says
|
|
236
|
+
// the same thing in another shape — a detached or not-yet-laid-out range.
|
|
237
|
+
if (rect === null || (rect.left === 0 && rect.top === 0 && rect.width === 0 && rect.height === 0)) return true;
|
|
238
|
+
return rect.left <= x + LEFT_TOLERANCE_PX;
|
|
239
|
+
}
|
|
240
|
+
|
|
202
241
|
/**
|
|
203
242
|
* The marked occurrence under a point.
|
|
204
243
|
*
|
|
@@ -213,6 +252,7 @@ function createHoverLayer(options) {
|
|
|
213
252
|
function matchAt(x, y) {
|
|
214
253
|
const caret = caretAt(x, y);
|
|
215
254
|
if (caret === null) return null;
|
|
255
|
+
if (!caretIsAt(x, caret)) return null;
|
|
216
256
|
const at = resolveCaret(caret, doc);
|
|
217
257
|
if (at === null) return null;
|
|
218
258
|
let best = null;
|
package/lib/core/store.js
CHANGED
|
@@ -201,6 +201,29 @@ function createDictionaryStore(options) {
|
|
|
201
201
|
return result.entry;
|
|
202
202
|
},
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Move a group, and everything under it, to another path.
|
|
206
|
+
* @param from - the path to move.
|
|
207
|
+
* @param to - the path to move it to.
|
|
208
|
+
* @returns a promise resolving to `{ moved, error }`.
|
|
209
|
+
*/
|
|
210
|
+
async renameGroup(from, to) {
|
|
211
|
+
const result = dictionary.renameGroup(state, from, to, { now: now() });
|
|
212
|
+
if (result.moved > 0) await commit(result.state);
|
|
213
|
+
return { moved: result.moved, error: result.error };
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Delete a group by deleting everything inside it, which is the only way a group can be deleted.
|
|
218
|
+
* @param from - the path to empty.
|
|
219
|
+
* @returns a promise resolving to `{ removed, error }`.
|
|
220
|
+
*/
|
|
221
|
+
async deleteGroup(from) {
|
|
222
|
+
const result = dictionary.deleteGroup(state, from, { now: now() });
|
|
223
|
+
if (result.removed > 0) await commit(result.state);
|
|
224
|
+
return { removed: result.removed, error: result.error };
|
|
225
|
+
},
|
|
226
|
+
|
|
204
227
|
/**
|
|
205
228
|
* Bring a deleted term back, as an explicit act.
|
|
206
229
|
*
|
package/lib/core/styles.js
CHANGED
|
@@ -133,6 +133,8 @@ const applyStyles = {
|
|
|
133
133
|
/** The group row's own bits: the mark that says "not an entry", and the chevron that says "opens". */
|
|
134
134
|
groupIcon: { display: "inline-flex", alignItems: "center", color: "var(--dsw-alias-label-secondary)" },
|
|
135
135
|
groupEnter: { color: "var(--dsw-alias-label-tertiary)", fontSize: "14px", lineHeight: 1 },
|
|
136
|
+
/** One line saying what is inside a group, so the card is not a name over a blank space. */
|
|
137
|
+
groupPreview: { margin: 0, fontSize: "12px", lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
|
|
136
138
|
/**
|
|
137
139
|
* The row's action toolbar: the second line, always.
|
|
138
140
|
*
|
package/lib/core/views.js
CHANGED
|
@@ -1623,6 +1623,13 @@ function DictionaryPanel(props) {
|
|
|
1623
1623
|
* because you looked at it — so it is panel state, next to the search box and the tab.
|
|
1624
1624
|
*/
|
|
1625
1625
|
const [group, setGroup] = React.useState("");
|
|
1626
|
+
/**
|
|
1627
|
+
* The group being renamed or moved, as `{ group, value }`, or null.
|
|
1628
|
+
*
|
|
1629
|
+
* An inline form rather than `prompt()`: Electron does not implement `prompt`, so a rename dialog
|
|
1630
|
+
* built on it would do nothing at all in the app this plugin runs in.
|
|
1631
|
+
*/
|
|
1632
|
+
const [renaming, setRenaming] = React.useState(null);
|
|
1626
1633
|
const visible = React.useMemo(() => store.list(query, filter, group), [view, query, filter, group]);
|
|
1627
1634
|
/**
|
|
1628
1635
|
* The groups directly inside the current one, each with how many entries it holds.
|
|
@@ -1665,6 +1672,43 @@ function DictionaryPanel(props) {
|
|
|
1665
1672
|
return moved;
|
|
1666
1673
|
};
|
|
1667
1674
|
|
|
1675
|
+
/**
|
|
1676
|
+
* Rename or move a group, and follow it if the panel was standing inside it.
|
|
1677
|
+
*
|
|
1678
|
+
* Following is the point: a reader who renames the folder they are in must still be looking at it
|
|
1679
|
+
* afterwards, not dropped back at the top wondering where it went.
|
|
1680
|
+
*/
|
|
1681
|
+
const commitRename = async () => {
|
|
1682
|
+
if (renaming === null) return;
|
|
1683
|
+
const result = await store.renameGroup(renaming.group, renaming.value);
|
|
1684
|
+
if (result.error !== null) {
|
|
1685
|
+
props.setToast?.(t(`groupError_${result.error}`));
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
const target = renaming.value.replace(/^\/+|\/+$/g, "");
|
|
1689
|
+
if (group === renaming.group || group.startsWith(`${renaming.group}/`)) {
|
|
1690
|
+
setGroup(group === renaming.group ? target : `${target}${group.slice(renaming.group.length)}`);
|
|
1691
|
+
}
|
|
1692
|
+
setRenaming(null);
|
|
1693
|
+
props.setToast?.(t("groupRenamed", { count: String(result.moved) }));
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
/**
|
|
1697
|
+
* Delete a group by deleting what is in it, after saying how much that is.
|
|
1698
|
+
*
|
|
1699
|
+
* The entries go to 「已删除」 rather than away, so this is reversible — and the count is in the
|
|
1700
|
+
* question, because "delete this group?" and "delete 12 entries?" are different offers.
|
|
1701
|
+
*/
|
|
1702
|
+
const removeGroup = async (sub) => {
|
|
1703
|
+
if (globalThis.confirm?.(t("groupDeleteConfirm", { name: sub.name, count: String(sub.count) })) !== true) return;
|
|
1704
|
+
const result = await store.deleteGroup(sub.group);
|
|
1705
|
+
if (result.error !== null) {
|
|
1706
|
+
props.setToast?.(t(`groupError_${result.error}`));
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
props.setToast?.(t("groupDeleted", { count: String(result.removed) }));
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1668
1712
|
/** Open the editor for a blank entry, prefilled from an optional term. */
|
|
1669
1713
|
const startCreate = (term = "", context = "") => {
|
|
1670
1714
|
setEditing({
|
|
@@ -2198,6 +2242,24 @@ function DictionaryPanel(props) {
|
|
|
2198
2242
|
// row. A breadcrumb says where you are and is the way back up, because a directory you can only
|
|
2199
2243
|
// enter is a trap.
|
|
2200
2244
|
[
|
|
2245
|
+
renaming === null ? null : h(
|
|
2246
|
+
"div",
|
|
2247
|
+
{ style: { ...applyStyles.transferRow, gridColumn: "1 / -1" }, "data-term-group-form": renaming.group },
|
|
2248
|
+
h("span", { style: applyStyles.fieldLabel }, t("groupRenameTo")),
|
|
2249
|
+
h("input", {
|
|
2250
|
+
type: "text",
|
|
2251
|
+
value: renaming.value,
|
|
2252
|
+
"aria-label": t("groupRename"),
|
|
2253
|
+
onChange: (event) => setRenaming({ ...renaming, value: event.target.value }),
|
|
2254
|
+
onKeyDown: (event) => {
|
|
2255
|
+
if (event.key === "Enter") void commitRename();
|
|
2256
|
+
if (event.key === "Escape") setRenaming(null);
|
|
2257
|
+
},
|
|
2258
|
+
style: { ...applyStyles.input, maxWidth: "18em" }
|
|
2259
|
+
}),
|
|
2260
|
+
h(Button, { label: t("groupRenameApply"), onClick: () => void commitRename() }, t("groupRenameApply")),
|
|
2261
|
+
h(Button, { label: t("cancel"), onClick: () => setRenaming(null) }, t("cancel"))
|
|
2262
|
+
),
|
|
2201
2263
|
group === "" ? null : h(
|
|
2202
2264
|
"div",
|
|
2203
2265
|
{ style: { ...applyStyles.breadcrumb, gridColumn: "1 / -1" }, "data-term-breadcrumb": group },
|
|
@@ -2227,16 +2289,19 @@ function DictionaryPanel(props) {
|
|
|
2227
2289
|
"article",
|
|
2228
2290
|
{
|
|
2229
2291
|
key: `group:${sub.group}`,
|
|
2230
|
-
//
|
|
2231
|
-
//
|
|
2232
|
-
|
|
2233
|
-
style: { ...applyStyles.row, alignSelf: "start" },
|
|
2292
|
+
// It fills its row again, now that the height carries something: with the preview below, the
|
|
2293
|
+
// space that used to be emptiness is the first thing you would see on opening it.
|
|
2294
|
+
style: applyStyles.row,
|
|
2234
2295
|
"data-term-group": sub.group,
|
|
2235
2296
|
// The WHOLE row opens the group, exactly as the whole row of an entry opens the entry.
|
|
2236
2297
|
// Only the label being live is what "the group cannot be clicked into" meant: a reader
|
|
2237
2298
|
// clicks the row, the same gesture that works one line below, and nothing happened.
|
|
2238
2299
|
// The label stays a button so it is reachable by keyboard and named for a screen reader.
|
|
2239
|
-
onClick: () =>
|
|
2300
|
+
onClick: (event) => {
|
|
2301
|
+
// The buttons inside keep their own meaning, exactly as they do on an entry row.
|
|
2302
|
+
if (typeof event?.target?.closest === "function" && event.target.closest("button") !== null) return;
|
|
2303
|
+
setGroup(sub.group);
|
|
2304
|
+
}
|
|
2240
2305
|
},
|
|
2241
2306
|
h("div", { style: applyStyles.rowHead },
|
|
2242
2307
|
h("div", { style: applyStyles.groupIcon, "aria-hidden": "true" }, Icons.folder(14)),
|
|
@@ -2249,6 +2314,24 @@ function DictionaryPanel(props) {
|
|
|
2249
2314
|
h("span", { style: applyStyles.badge }, t("groupCount", { count: String(sub.count) })),
|
|
2250
2315
|
h("span", { style: applyStyles.spacer }),
|
|
2251
2316
|
h("span", { style: applyStyles.groupEnter, "aria-hidden": "true" }, "›")
|
|
2317
|
+
),
|
|
2318
|
+
// The same shape an entry row has: a title line, a line of actions, then the body.
|
|
2319
|
+
h(
|
|
2320
|
+
"div",
|
|
2321
|
+
{ style: applyStyles.rowActions, "data-term-group-actions": sub.group },
|
|
2322
|
+
h(IconButton, { title: t("groupRename"), onClick: () => setRenaming({ group: sub.group, value: sub.group }) }, Icons.edit(13)),
|
|
2323
|
+
h(IconButton, { title: t("groupDelete"), onClick: () => void removeGroup(sub) }, Icons.trash(13))
|
|
2324
|
+
),
|
|
2325
|
+
// What is inside, in one line: the row stops being a name over a blank card.
|
|
2326
|
+
h(
|
|
2327
|
+
"p",
|
|
2328
|
+
{ style: applyStyles.groupPreview, "data-term-group-sample": sub.group },
|
|
2329
|
+
[
|
|
2330
|
+
sub.sample.join(t("groupSampleJoin")),
|
|
2331
|
+
sub.count > sub.sample.length ? t("groupSampleMore", { count: String(sub.count - sub.sample.length) }) : ""
|
|
2332
|
+
]
|
|
2333
|
+
.filter((part) => part !== "")
|
|
2334
|
+
.join("")
|
|
2252
2335
|
)
|
|
2253
2336
|
)
|
|
2254
2337
|
),
|