dsh-plugin-term-dictionary 0.0.0-stage → 1.0.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 +128 -0
- package/LICENSE +21 -0
- package/README.md +772 -2
- package/cordis.patch.yml +14 -0
- package/icon.svg +13 -0
- package/lib/ROADMAP-lexicon.md +52 -0
- package/lib/client.js +12586 -0
- package/lib/core/api.js +278 -0
- package/lib/core/bus.js +98 -0
- package/lib/core/copy.js +552 -0
- package/lib/core/core.js +309 -0
- package/lib/core/dictionary.js +1083 -0
- package/lib/core/entries.js +419 -0
- package/lib/core/highlight.js +282 -0
- package/lib/core/hover.js +470 -0
- package/lib/core/hovercard.js +173 -0
- package/lib/core/interact.js +1802 -0
- package/lib/core/lexicon.en.js +872 -0
- package/lib/core/lexicon.zh.js +249 -0
- package/lib/core/overlay.js +239 -0
- package/lib/core/pack.js +345 -0
- package/lib/core/package.json +4 -0
- package/lib/core/selection.js +83 -0
- package/lib/core/settings.js +312 -0
- package/lib/core/shell.js +1003 -0
- package/lib/core/stopwords.js +147 -0
- package/lib/core/store.js +370 -0
- package/lib/core/styles.js +557 -0
- package/lib/core/terms.js +398 -0
- package/lib/core/transfer.js +312 -0
- package/lib/core/views.js +2056 -0
- package/lib/index.js +1110 -0
- package/lib/pack-code.js +84 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -3
package/lib/core/pack.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Term packs: a dictionary's worth of entries as one shareable artifact.
|
|
5
|
+
*
|
|
6
|
+
* A pack is what a user hands to another user — a topical glossary, a team's vocabulary, a domain's
|
|
7
|
+
* jargon. It is deliberately NOT a dictionary file: {@link module:transfer} exports entries plus the
|
|
8
|
+
* local history that makes them a snapshot of THIS machine, while a pack carries only what a reader
|
|
9
|
+
* needs to use the terms, and never anything about the person who made it.
|
|
10
|
+
*
|
|
11
|
+
* What a pack therefore cannot contain, and why it matters:
|
|
12
|
+
*
|
|
13
|
+
* - **`context`** — the sentence a term appeared in. That is a piece of a private conversation.
|
|
14
|
+
* - **`notes`** — the user's own remarks about an entry, which {@link module:transfer}'s record shape
|
|
15
|
+
* already leaves out and which a pack must not add back.
|
|
16
|
+
* - **`feedback` / `untrusted`** — what the AUTHOR thinks of an explanation. Someone else's verdict
|
|
17
|
+
* arriving as if it were yours would be worse than no verdict at all; the merge would also treat it
|
|
18
|
+
* as a decision, which is exactly the kind of state a shared artifact must not be able to write.
|
|
19
|
+
* - **`pinned` / `source` / `seen` / `lastSeenAt` / `createdAt`** — local preferences and history.
|
|
20
|
+
* A pack that pinned its own entries would rearrange the reader's list.
|
|
21
|
+
*
|
|
22
|
+
* The entries themselves use the same record shape as an imported file (`term`, `key`, `definition.{zh,
|
|
23
|
+
* gloss,usage}`, `domain`, `aliases`), which is what lets an imported pack go through the import path
|
|
24
|
+
* this plugin already has — the duplicate report, the deleted-term opt-in and the crafted revival are
|
|
25
|
+
* all reused rather than reimplemented.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const transfer = require("./transfer.js");
|
|
29
|
+
|
|
30
|
+
/** The envelope's marker, so a pack is never mistaken for a dictionary to import. */
|
|
31
|
+
const PACK_KIND = "dsh-term-dictionary-pack";
|
|
32
|
+
|
|
33
|
+
/** The envelope version. Bumped if the shape changes in a way an older reader must know about. */
|
|
34
|
+
const PACK_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a share code starts with.
|
|
38
|
+
*
|
|
39
|
+
* Declared here rather than next to the compressor because it is part of the FORMAT: the panel checks
|
|
40
|
+
* it before a paste is sent anywhere, and the host checks it before it decompresses anything. One
|
|
41
|
+
* definition, two readers — the compressor (`lib/pack-code.js`) imports this constant.
|
|
42
|
+
*/
|
|
43
|
+
const PACK_CODE_PREFIX = "dshpack1:";
|
|
44
|
+
|
|
45
|
+
/** Upper bounds, so a hand-written pack or index cannot bloat the panel. */
|
|
46
|
+
const MAX_PACK_TEXT = 160;
|
|
47
|
+
const MAX_PACK_ENTRIES = 2000;
|
|
48
|
+
|
|
49
|
+
/** The index's marker and version: the file a source URL points at, listing packs. */
|
|
50
|
+
const INDEX_KIND = "dsh-term-dictionary-index";
|
|
51
|
+
const INDEX_VERSION = 1;
|
|
52
|
+
|
|
53
|
+
/** The most packs one index may offer, and the longest a pack URL may be. */
|
|
54
|
+
const MAX_INDEX_PACKS = 500;
|
|
55
|
+
const MAX_URL = 600;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a URL may be fetched at all.
|
|
59
|
+
*
|
|
60
|
+
* **https only.** A plugin that fetches whatever a field says is a plugin that can be pointed at a
|
|
61
|
+
* local file, a `data:` URL or a plaintext LAN endpoint, and none of those are a term-pack source. The
|
|
62
|
+
* check is on the URL STRING, so it holds for a cached copy and for an injected transport alike.
|
|
63
|
+
*
|
|
64
|
+
* @param value - the candidate URL.
|
|
65
|
+
* @returns null when it may be fetched, or the reason it may not.
|
|
66
|
+
*/
|
|
67
|
+
function refuseUrl(value) {
|
|
68
|
+
if (typeof value !== "string" || value.trim() === "") return "no-url";
|
|
69
|
+
if (value.length > MAX_URL) return "url-too-long";
|
|
70
|
+
let url;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(value.trim());
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return "bad-url";
|
|
75
|
+
}
|
|
76
|
+
if (url.protocol !== "https:") return "not-https";
|
|
77
|
+
if (url.hostname === "") return "bad-url";
|
|
78
|
+
// Credentials in a URL are a leak waiting to happen: they end up in the cache file and in any log
|
|
79
|
+
// that prints the URL, and no pack source needs them.
|
|
80
|
+
if (url.username !== "" || url.password !== "") return "credentials-in-url";
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Clamp one short text field. */
|
|
85
|
+
function clamp(value, limit = MAX_PACK_TEXT) {
|
|
86
|
+
if (typeof value !== "string") return "";
|
|
87
|
+
const trimmed = value.trim();
|
|
88
|
+
if (trimmed.length <= limit) return trimmed;
|
|
89
|
+
return `${trimmed.slice(0, limit - 1)}…`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One entry as a pack carries it: content, and nothing about the machine it came from.
|
|
94
|
+
*
|
|
95
|
+
* The field names match {@link module:transfer}'s record shape on purpose — validation of an incoming
|
|
96
|
+
* pack is then the SAME validation as an incoming file, which is the difference between one gate and
|
|
97
|
+
* two that drift.
|
|
98
|
+
*
|
|
99
|
+
* @param entry - an entry from the store.
|
|
100
|
+
* @returns the portable record.
|
|
101
|
+
*/
|
|
102
|
+
function packRecord(entry) {
|
|
103
|
+
const definition = entry?.definition ?? {};
|
|
104
|
+
const record = {
|
|
105
|
+
term: typeof entry?.term === "string" ? entry.term : "",
|
|
106
|
+
key: typeof entry?.key === "string" ? entry.key : "",
|
|
107
|
+
definition: {
|
|
108
|
+
zh: typeof definition.zh === "string" ? definition.zh : "",
|
|
109
|
+
gloss: typeof definition.gloss === "string" ? definition.gloss : "",
|
|
110
|
+
usage: typeof definition.usage === "string" ? definition.usage : ""
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
if (typeof entry?.domain === "string" && entry.domain !== "") record.domain = entry.domain;
|
|
114
|
+
if (Array.isArray(entry?.aliases) && entry.aliases.length > 0) record.aliases = [...entry.aliases];
|
|
115
|
+
return record;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build a pack from the entries a caller offers.
|
|
120
|
+
*
|
|
121
|
+
* Deleted entries are refused here rather than filtered by the caller: a tombstone is the record of a
|
|
122
|
+
* decision this machine made, and publishing one would be publishing somebody's deletion.
|
|
123
|
+
*
|
|
124
|
+
* @param entries - every entry the caller may publish.
|
|
125
|
+
* @param options - `id`, `name`, `description`, `author`, `license`, `homepage`, `build`, `scope`
|
|
126
|
+
* (`"all"` or `"domains"`), `domains`, `now`.
|
|
127
|
+
* @returns `{ ok: true, pack }`, or `{ ok: false, error }` when there is nothing to publish.
|
|
128
|
+
*/
|
|
129
|
+
function buildPack(entries, options) {
|
|
130
|
+
const all = (Array.isArray(entries) ? entries : []).filter((entry) => (entry?.deletedAt ?? 0) === 0);
|
|
131
|
+
// Selecting no category is a statement, exactly as it is for an export: publishing everything
|
|
132
|
+
// instead would be the wrong reading of it.
|
|
133
|
+
const scope = options?.scope === "domains" ? "domains" : "all";
|
|
134
|
+
const chosen = new Set(Array.isArray(options?.domains) ? options.domains : []);
|
|
135
|
+
const picked = scope === "all" ? all : all.filter((entry) => chosen.has(typeof entry?.domain === "string" ? entry.domain.trim() : ""));
|
|
136
|
+
if (picked.length === 0) return { ok: false, error: scope === "all" ? "empty" : "empty-scope" };
|
|
137
|
+
if (picked.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
|
|
138
|
+
// Sorted by key, so the same dictionary always produces the same bytes and therefore the same
|
|
139
|
+
// checksum: two people publishing the same glossary get the same artifact, and a reader can tell
|
|
140
|
+
// "same content" from "same file" without trusting anything.
|
|
141
|
+
const records = picked.map(packRecord).sort((left, right) => left.key.localeCompare(right.key) || left.term.localeCompare(right.term));
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
pack: {
|
|
145
|
+
kind: PACK_KIND,
|
|
146
|
+
version: PACK_VERSION,
|
|
147
|
+
id: clamp(options?.id) || "pack",
|
|
148
|
+
name: clamp(options?.name) || clamp(options?.id) || "pack",
|
|
149
|
+
description: clamp(options?.description, 400),
|
|
150
|
+
author: clamp(options?.author),
|
|
151
|
+
license: clamp(options?.license),
|
|
152
|
+
homepage: clamp(options?.homepage, 400),
|
|
153
|
+
build: clamp(options?.build),
|
|
154
|
+
createdAt: typeof options?.now === "number" ? options.now : Date.now(),
|
|
155
|
+
scope: { kind: scope, domains: scope === "domains" ? [...chosen].sort() : [] },
|
|
156
|
+
count: records.length,
|
|
157
|
+
entries: records
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Read a pack from whatever arrived: a fetched file, or a decoded share code.
|
|
164
|
+
*
|
|
165
|
+
* Tolerant about unknown keys in the envelope, strict about the two things that make it a pack: its
|
|
166
|
+
* marker and at least one usable entry. Every record goes through the same validator an imported file
|
|
167
|
+
* does, so a record that cannot be imported is a record this refuses.
|
|
168
|
+
*
|
|
169
|
+
* @param raw - the parsed value.
|
|
170
|
+
* @returns `{ ok: true, pack }`, or `{ ok: false, error }`.
|
|
171
|
+
*/
|
|
172
|
+
function parsePack(raw) {
|
|
173
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "not-an-object" };
|
|
174
|
+
if (raw.kind !== PACK_KIND) return { ok: false, error: "not-a-pack" };
|
|
175
|
+
if (raw.version !== PACK_VERSION) return { ok: false, error: "unsupported-version" };
|
|
176
|
+
if (!Array.isArray(raw.entries)) return { ok: false, error: "no-entries" };
|
|
177
|
+
if (raw.entries.length > MAX_PACK_ENTRIES) return { ok: false, error: "too-many" };
|
|
178
|
+
const entries = [];
|
|
179
|
+
for (const record of raw.entries) {
|
|
180
|
+
const entry = transfer.toEntry(record);
|
|
181
|
+
if (entry !== null) entries.push(record);
|
|
182
|
+
}
|
|
183
|
+
if (entries.length === 0) return { ok: false, error: "no-usable-entries" };
|
|
184
|
+
const scope = raw.scope !== null && typeof raw.scope === "object" && raw.scope.kind === "domains" ? "domains" : "all";
|
|
185
|
+
return {
|
|
186
|
+
ok: true,
|
|
187
|
+
pack: {
|
|
188
|
+
kind: PACK_KIND,
|
|
189
|
+
version: PACK_VERSION,
|
|
190
|
+
id: clamp(raw.id) || "pack",
|
|
191
|
+
name: clamp(raw.name) || clamp(raw.id) || "pack",
|
|
192
|
+
description: clamp(raw.description, 400),
|
|
193
|
+
author: clamp(raw.author),
|
|
194
|
+
license: clamp(raw.license),
|
|
195
|
+
homepage: clamp(raw.homepage, 400),
|
|
196
|
+
build: clamp(raw.build),
|
|
197
|
+
createdAt: typeof raw.createdAt === "number" && Number.isFinite(raw.createdAt) ? Math.floor(raw.createdAt) : 0,
|
|
198
|
+
scope: { kind: scope, domains: scope === "domains" && Array.isArray(raw.scope.domains) ? raw.scope.domains.filter((domain) => typeof domain === "string") : [] },
|
|
199
|
+
// The count is the number of records a reader will actually get, not the number the file
|
|
200
|
+
// claimed: a pack whose records are half unusable must not read as fully usable.
|
|
201
|
+
count: entries.length,
|
|
202
|
+
entries
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The bytes a pack is published as, and therefore the bytes its checksum covers.
|
|
209
|
+
*
|
|
210
|
+
* `buildPack` sorts the records, so this is deterministic for a given dictionary — the property that
|
|
211
|
+
* lets two publishers of the same glossary produce one artifact, and lets a reader compare a fetched
|
|
212
|
+
* pack against the checksum an index promised.
|
|
213
|
+
*
|
|
214
|
+
* @param pack - a built or parsed pack.
|
|
215
|
+
* @returns the JSON text.
|
|
216
|
+
*/
|
|
217
|
+
function packBytes(pack) {
|
|
218
|
+
return JSON.stringify(pack);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* What a pack contains, for a list that has to describe it in one line.
|
|
223
|
+
*
|
|
224
|
+
* @param pack - a built or parsed pack.
|
|
225
|
+
* @returns `{ count, explained, domains }`, where `explained` counts entries with an explanation.
|
|
226
|
+
*/
|
|
227
|
+
function summarizePack(pack) {
|
|
228
|
+
const entries = Array.isArray(pack?.entries) ? pack.entries : [];
|
|
229
|
+
const counts = new Map();
|
|
230
|
+
let explained = 0;
|
|
231
|
+
for (const record of entries) {
|
|
232
|
+
const definition = record?.definition ?? {};
|
|
233
|
+
if ((typeof definition.gloss === "string" && definition.gloss !== "") || (typeof definition.zh === "string" && definition.zh !== "")) explained++;
|
|
234
|
+
const domain = typeof record?.domain === "string" ? record.domain.trim() : "";
|
|
235
|
+
if (domain !== "") counts.set(domain, (counts.get(domain) ?? 0) + 1);
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
count: entries.length,
|
|
239
|
+
explained,
|
|
240
|
+
domains: [...counts.entries()].map(([domain, count]) => ({ domain, count })).sort((left, right) => right.count - left.count || left.domain.localeCompare(right.domain))
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Build the index a source URL serves: the list a reader browses before fetching any pack.
|
|
246
|
+
*
|
|
247
|
+
* @param packs - `{ id, name, description, author, license, domains, count, sha256, url }` per pack.
|
|
248
|
+
* @param options - `now`.
|
|
249
|
+
* @returns the index envelope.
|
|
250
|
+
*/
|
|
251
|
+
function buildIndex(packs, options) {
|
|
252
|
+
const listed = (Array.isArray(packs) ? packs : [])
|
|
253
|
+
.filter((pack) => pack !== null && typeof pack === "object" && refuseUrl(pack.url) === null)
|
|
254
|
+
.map((pack) => ({
|
|
255
|
+
id: clamp(pack.id) || "pack",
|
|
256
|
+
name: clamp(pack.name) || clamp(pack.id) || "pack",
|
|
257
|
+
description: clamp(pack.description, 400),
|
|
258
|
+
author: clamp(pack.author),
|
|
259
|
+
license: clamp(pack.license),
|
|
260
|
+
domains: (Array.isArray(pack.domains) ? pack.domains : []).filter((domain) => typeof domain === "string" && domain !== "").slice(0, 40),
|
|
261
|
+
count: typeof pack.count === "number" && Number.isFinite(pack.count) && pack.count >= 0 ? Math.floor(pack.count) : 0,
|
|
262
|
+
sha256: typeof pack.sha256 === "string" ? pack.sha256.toLowerCase() : "",
|
|
263
|
+
url: pack.url
|
|
264
|
+
}))
|
|
265
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
266
|
+
.slice(0, MAX_INDEX_PACKS);
|
|
267
|
+
return {
|
|
268
|
+
kind: INDEX_KIND,
|
|
269
|
+
version: INDEX_VERSION,
|
|
270
|
+
updatedAt: typeof options?.now === "number" ? options.now : Date.now(),
|
|
271
|
+
count: listed.length,
|
|
272
|
+
packs: listed
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read an index.
|
|
278
|
+
*
|
|
279
|
+
* Entries that cannot be used are DROPPED and counted rather than failing the whole file: one bad row
|
|
280
|
+
* in somebody's index must not hide the other forty packs. An index with nothing usable left is a
|
|
281
|
+
* refusal, because a source that offers nothing is a source that is misconfigured or moved.
|
|
282
|
+
*
|
|
283
|
+
* @param raw - the parsed value.
|
|
284
|
+
* @returns `{ ok: true, index, dropped }`, or `{ ok: false, error }`.
|
|
285
|
+
*/
|
|
286
|
+
function parseIndex(raw) {
|
|
287
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: "not-an-object" };
|
|
288
|
+
if (raw.kind !== INDEX_KIND) return { ok: false, error: "not-an-index" };
|
|
289
|
+
if (raw.version !== INDEX_VERSION) return { ok: false, error: "unsupported-version" };
|
|
290
|
+
if (!Array.isArray(raw.packs)) return { ok: false, error: "no-packs" };
|
|
291
|
+
const packs = [];
|
|
292
|
+
let dropped = 0;
|
|
293
|
+
for (const pack of raw.packs) {
|
|
294
|
+
if (pack === null || typeof pack !== "object") {
|
|
295
|
+
dropped++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// The url is the one field a bad value makes dangerous rather than merely useless.
|
|
299
|
+
if (refuseUrl(pack.url) !== null || (typeof pack.id !== "string" && typeof pack.name !== "string")) {
|
|
300
|
+
dropped++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
packs.push({
|
|
304
|
+
id: clamp(pack.id) || clamp(pack.name) || "pack",
|
|
305
|
+
name: clamp(pack.name) || clamp(pack.id) || "pack",
|
|
306
|
+
description: clamp(pack.description, 400),
|
|
307
|
+
author: clamp(pack.author),
|
|
308
|
+
license: clamp(pack.license),
|
|
309
|
+
domains: (Array.isArray(pack.domains) ? pack.domains : []).filter((domain) => typeof domain === "string" && domain !== "").slice(0, 40),
|
|
310
|
+
count: typeof pack.count === "number" && Number.isFinite(pack.count) && pack.count >= 0 ? Math.floor(pack.count) : 0,
|
|
311
|
+
sha256: typeof pack.sha256 === "string" ? pack.sha256.toLowerCase() : "",
|
|
312
|
+
url: String(pack.url).trim()
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (packs.length === 0) return { ok: false, error: "no-usable-packs" };
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
dropped,
|
|
319
|
+
index: {
|
|
320
|
+
kind: INDEX_KIND,
|
|
321
|
+
version: INDEX_VERSION,
|
|
322
|
+
updatedAt: typeof raw.updatedAt === "number" && Number.isFinite(raw.updatedAt) ? Math.floor(raw.updatedAt) : 0,
|
|
323
|
+
count: packs.length,
|
|
324
|
+
packs: packs.slice(0, MAX_INDEX_PACKS)
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
PACK_KIND,
|
|
331
|
+
PACK_VERSION,
|
|
332
|
+
PACK_CODE_PREFIX,
|
|
333
|
+
INDEX_KIND,
|
|
334
|
+
INDEX_VERSION,
|
|
335
|
+
MAX_PACK_ENTRIES,
|
|
336
|
+
MAX_INDEX_PACKS,
|
|
337
|
+
buildPack,
|
|
338
|
+
parsePack,
|
|
339
|
+
buildIndex,
|
|
340
|
+
parseIndex,
|
|
341
|
+
refuseUrl,
|
|
342
|
+
packRecord,
|
|
343
|
+
packBytes,
|
|
344
|
+
summarizePack
|
|
345
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Scope marker, not a package. The plugin package is ESM (`\"type\": \"module\"`), so a plain `.js` under lib/ would be parsed as a module — which breaks both the host half's imports and the browser bundle's need for callable module.exports values. Declaring the type in this subdirectory keeps the core CommonJS while lib/index.js stays ESM. Renaming to .cjs is not an option: the client bundle's own require cannot resolve either spelling at runtime.",
|
|
3
|
+
"type": "commonjs"
|
|
4
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The document's text selection, in the one place every gesture layer asks about it.
|
|
5
|
+
*
|
|
6
|
+
* This module exists because "was that click a selection gesture?" is asked in three places — the
|
|
7
|
+
* panel's rows, the annotated-term click in `hover.js`, and the unannotated-word click in
|
|
8
|
+
* `interact.js` — and the answer has to be the same in all three or the bug simply moves:
|
|
9
|
+
*
|
|
10
|
+
* - the panel opened its editor on the click that ended a selection made to copy a gloss;
|
|
11
|
+
* - the transcript switched to the dictionary on the click that ended a selection made to copy a
|
|
12
|
+
* sentence that happened to end on a marked term.
|
|
13
|
+
*
|
|
14
|
+
* Both are the same mistake — treating a click that FINISHES a selection as a click that CHOOSES
|
|
15
|
+
* something — and this is the shared answer to it.
|
|
16
|
+
*
|
|
17
|
+
* The check is deliberately conservative in one direction only: a selection that is whitespace, or
|
|
18
|
+
* collapsed to a caret, is NOT a selection gesture, because dragging across trailing spaces is not a
|
|
19
|
+
* copy anybody is trying to make. Nothing here prevents a gesture; it only declines to act.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read the live selection, preferring the host the caller was given.
|
|
24
|
+
*
|
|
25
|
+
* The preference matters: a layer built against a specific document or window must read THAT one, and
|
|
26
|
+
* a component with no host of its own (the panel) reads the page's. `globalThis` is the fallback
|
|
27
|
+
* rather than the first choice for exactly that reason.
|
|
28
|
+
*
|
|
29
|
+
* @param host - the document or window to ask first, or undefined.
|
|
30
|
+
* @returns the selection, or null when there is none to read.
|
|
31
|
+
*/
|
|
32
|
+
function readSelection(host) {
|
|
33
|
+
if (host !== null && host !== undefined && typeof host.getSelection === "function") {
|
|
34
|
+
const local = host.getSelection();
|
|
35
|
+
if (local !== null && local !== undefined) return local;
|
|
36
|
+
}
|
|
37
|
+
if (typeof globalThis.getSelection === "function") {
|
|
38
|
+
const fallback = globalThis.getSelection();
|
|
39
|
+
if (fallback !== null && fallback !== undefined) return fallback;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether the user has text selected right now.
|
|
46
|
+
*
|
|
47
|
+
* @param host - the document or window to ask first, or undefined.
|
|
48
|
+
* @returns true for a non-empty, non-whitespace selection that is not merely a caret.
|
|
49
|
+
*/
|
|
50
|
+
function selectionActive(host) {
|
|
51
|
+
const selection = readSelection(host);
|
|
52
|
+
if (selection === null || typeof selection.toString !== "function") return false;
|
|
53
|
+
// `isCollapsed` is the cheap discriminator and `toString()` the decisive one: a collapsed selection
|
|
54
|
+
// has nothing to copy, and a browser that reports the flag wrongly is still caught by the text.
|
|
55
|
+
if (selection.isCollapsed === true) return false;
|
|
56
|
+
return selection.toString().trim() !== "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whether a non-empty selection lies inside one element.
|
|
61
|
+
*
|
|
62
|
+
* Used by the panel, where the question is narrower than "is anything selected": clicking a row right
|
|
63
|
+
* after selecting part of a REPLY is still a click on the row, and only a selection inside that row
|
|
64
|
+
* means the click was finishing a selection of the row's own text.
|
|
65
|
+
*
|
|
66
|
+
* Every read is defensive: `contains` is absent in the test sandbox, and a row with no selection must
|
|
67
|
+
* behave exactly as it did before this guard existed.
|
|
68
|
+
*
|
|
69
|
+
* @param element - the element the click happened in.
|
|
70
|
+
* @param host - the document or window to ask first, or undefined.
|
|
71
|
+
* @returns true when a non-empty selection lies inside it.
|
|
72
|
+
*/
|
|
73
|
+
function selectionInside(element, host) {
|
|
74
|
+
if (element === null || element === undefined) return false;
|
|
75
|
+
if (selectionActive(host) !== true) return false;
|
|
76
|
+
if (typeof element.contains !== "function") return false;
|
|
77
|
+
const selection = readSelection(host);
|
|
78
|
+
const node = selection?.anchorNode ?? selection?.focusNode ?? null;
|
|
79
|
+
if (node === null || node === undefined) return false;
|
|
80
|
+
return element.contains(node) === true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { readSelection, selectionActive, selectionInside };
|