@walkinissue/angy 0.2.17
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/LICENSE +21 -0
- package/README.md +246 -0
- package/dist/client/Angy.svelte +787 -0
- package/dist/client/PendingChangesDialog.svelte +109 -0
- package/dist/client/TranslationAlternativeItem.svelte +309 -0
- package/dist/client/TranslationHelperForm.svelte +645 -0
- package/dist/client/VibeTooltip.svelte +146 -0
- package/dist/client/dragItem.ts +50 -0
- package/dist/client/toggleQA.shared.ts +164 -0
- package/dist/client/translationSuggestions.ts +100 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plugin.d.ts +26 -0
- package/dist/plugin.js +288 -0
- package/dist/server/types.ts +40 -0
- package/dist/server.d.ts +95 -0
- package/dist/server.js +1094 -0
- package/dist/server.js.map +7 -0
- package/package.json +85 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
<!-- @wc-ignore-file -->
|
|
2
|
+
<script lang="ts">
|
|
3
|
+
import { browser, dev } from "$app/environment";
|
|
4
|
+
import { page } from "$app/state";
|
|
5
|
+
import { loadLocale } from "wuchale/load-utils";
|
|
6
|
+
import { onMount } from "svelte";
|
|
7
|
+
import { draggable } from "./dragItem";
|
|
8
|
+
import PendingChangesDialog from "./PendingChangesDialog.svelte";
|
|
9
|
+
import TranslationHelperForm from "./TranslationHelperForm.svelte";
|
|
10
|
+
import {
|
|
11
|
+
readSuggestionCache,
|
|
12
|
+
requestTranslationSuggestions
|
|
13
|
+
} from "./translationSuggestions";
|
|
14
|
+
import {
|
|
15
|
+
getCandidateList,
|
|
16
|
+
getEffectiveResolvedKey,
|
|
17
|
+
getEntryOrigin,
|
|
18
|
+
getEntryOriginalValue,
|
|
19
|
+
sameResolvedKey,
|
|
20
|
+
translationKey,
|
|
21
|
+
type DraftTranslation,
|
|
22
|
+
type ResolvedKey,
|
|
23
|
+
type TranslationContextResult
|
|
24
|
+
} from "./toggleQA.shared";
|
|
25
|
+
|
|
26
|
+
const runtimeConfig = globalThis as typeof globalThis & {
|
|
27
|
+
__ANGY_ROUTE_PATH__?: string;
|
|
28
|
+
__ANGY_LOCALES__?: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const defaultEndpoint =
|
|
32
|
+
typeof runtimeConfig.__ANGY_ROUTE_PATH__ !== "undefined"
|
|
33
|
+
? runtimeConfig.__ANGY_ROUTE_PATH__
|
|
34
|
+
: "/api/translations";
|
|
35
|
+
const defaultLocales =
|
|
36
|
+
Array.isArray(runtimeConfig.__ANGY_LOCALES__) && runtimeConfig.__ANGY_LOCALES__.length
|
|
37
|
+
? [...runtimeConfig.__ANGY_LOCALES__]
|
|
38
|
+
: ["en", "sv"];
|
|
39
|
+
|
|
40
|
+
let { endpoint = defaultEndpoint } = $props<{
|
|
41
|
+
endpoint?: string;
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
let dragQua = $state(true);
|
|
45
|
+
let selectedResolvedKey = $state<ResolvedKey | null>(null);
|
|
46
|
+
let focusedAltKey = $state<string | null>(null);
|
|
47
|
+
|
|
48
|
+
let contextPending = $state(false);
|
|
49
|
+
let contextError = $state<string | null>(null);
|
|
50
|
+
let contextResult = $state<TranslationContextResult | null>(null);
|
|
51
|
+
|
|
52
|
+
let stagedTranslations = $state<Record<string, DraftTranslation>>({});
|
|
53
|
+
|
|
54
|
+
let currentLocale = $state(defaultLocales);
|
|
55
|
+
let locale = 0;
|
|
56
|
+
|
|
57
|
+
let activeSwitchLocale = $state(true);
|
|
58
|
+
let selectionStarted = false;
|
|
59
|
+
let capturedSelection: string | undefined = $state(undefined);
|
|
60
|
+
let spawnTranslation = $state(false);
|
|
61
|
+
let translatedValue = $state("");
|
|
62
|
+
|
|
63
|
+
let pending = $state(false);
|
|
64
|
+
let rotatePending = $state(false);
|
|
65
|
+
let error = $state<string | null>(null);
|
|
66
|
+
let success = $state<string | null>(null);
|
|
67
|
+
let renderedLocale = $state<string>(currentLocale[locale] ?? "en");
|
|
68
|
+
let showPendingChangesDialog = $state(false);
|
|
69
|
+
let suggestionLookup = $state<Record<string, string>>(readSuggestionCache());
|
|
70
|
+
let suggestionPending = $state(false);
|
|
71
|
+
|
|
72
|
+
let panelEl: HTMLDivElement | null = null;
|
|
73
|
+
let translationInputEl: HTMLTextAreaElement | null = null;
|
|
74
|
+
|
|
75
|
+
function setTranslationInputEl(element: HTMLTextAreaElement | null) {
|
|
76
|
+
translationInputEl = element;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getLocale(increase = true) {
|
|
80
|
+
if (increase) {
|
|
81
|
+
locale = locale >= currentLocale.length - 1 ? 0 : locale + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
renderedLocale = currentLocale[locale];
|
|
85
|
+
return currentLocale[locale];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function focusTranslationInput() {
|
|
89
|
+
queueMicrotask(() => {
|
|
90
|
+
translationInputEl?.focus();
|
|
91
|
+
translationInputEl?.select();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleLocaleToggle(event: MouseEvent) {
|
|
96
|
+
if (activeSwitchLocale) return;
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
loadLocale(getLocale());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getStaged(msgid: string, msgctxt: string | null) {
|
|
102
|
+
return stagedTranslations[translationKey(msgid, msgctxt)] ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function getSuggestion(msgid: string, msgctxt: string | null) {
|
|
106
|
+
return suggestionLookup[translationKey(msgid, msgctxt)] ?? "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isStaged(msgid: string, msgctxt: string | null) {
|
|
110
|
+
return Boolean(getStaged(msgid, msgctxt));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isSelectedAlt(msgid: string, msgctxt: string | null) {
|
|
114
|
+
return sameResolvedKey(selectedResolvedKey, { msgid, msgctxt });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function setFocusedAltKey(key: string | null) {
|
|
118
|
+
focusedAltKey = key;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resetFeedback() {
|
|
122
|
+
error = null;
|
|
123
|
+
success = null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function rotateCatalogs() {
|
|
127
|
+
if (rotatePending || pending) return;
|
|
128
|
+
if (!confirm("Rotate catalogs? This will back up both catalogs and promote the working catalog into the base catalog.")) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
rotatePending = true;
|
|
133
|
+
resetFeedback();
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(`${endpoint}?intent=rotate-catalogs`, {
|
|
137
|
+
method: "POST"
|
|
138
|
+
});
|
|
139
|
+
const payload = await response.json().catch(() => null);
|
|
140
|
+
|
|
141
|
+
if (!response.ok) {
|
|
142
|
+
error = payload?.error ?? "Catalog rotation failed";
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
success = payload?.message ?? "Catalogs rotated";
|
|
147
|
+
} catch {
|
|
148
|
+
error = "Catalog rotation failed";
|
|
149
|
+
} finally {
|
|
150
|
+
rotatePending = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function resetContextState() {
|
|
155
|
+
contextError = null;
|
|
156
|
+
contextResult = null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function hasDirtyStagedTranslations() {
|
|
160
|
+
return Object.values(stagedTranslations).some((item) => item.isDirty);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function applyCommittedTranslations(
|
|
164
|
+
currentContext: TranslationContextResult | null,
|
|
165
|
+
items: DraftTranslation[]
|
|
166
|
+
): TranslationContextResult | null {
|
|
167
|
+
if (!currentContext || !items.length) return currentContext;
|
|
168
|
+
|
|
169
|
+
const updates = new Map(
|
|
170
|
+
items.map((item) => [translationKey(item.msgid, item.msgctxt), item.value])
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const updateEntry = <T extends { msgid: string; msgctxt: string | null; msgstr: string[]; hasTranslation?: boolean; isCommittedToWorking?: boolean; isFuzzy?: boolean | null; translationOrigin?: "base" | "working" | null }>(
|
|
174
|
+
entry: T
|
|
175
|
+
): T => {
|
|
176
|
+
const nextValue = updates.get(translationKey(entry.msgid, entry.msgctxt));
|
|
177
|
+
if (!nextValue) return entry;
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...entry,
|
|
181
|
+
msgstr: [nextValue],
|
|
182
|
+
hasTranslation: true,
|
|
183
|
+
isCommittedToWorking: true,
|
|
184
|
+
matchesTargetTranslation: false,
|
|
185
|
+
isFuzzy: false,
|
|
186
|
+
translationOrigin: "working"
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
...currentContext,
|
|
192
|
+
entry: updateEntry(currentContext.entry),
|
|
193
|
+
alternatives: currentContext.alternatives.map((alternative) => updateEntry(alternative))
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function selectResolvedKey(msgid: string, msgctxt: string | null, focusInput = false) {
|
|
198
|
+
selectedResolvedKey = { msgid, msgctxt };
|
|
199
|
+
translatedValue =
|
|
200
|
+
getStaged(msgid, msgctxt)?.value ||
|
|
201
|
+
getSuggestion(msgid, msgctxt) ||
|
|
202
|
+
getEntryOriginalValue(contextResult, msgid, msgctxt);
|
|
203
|
+
|
|
204
|
+
resetFeedback();
|
|
205
|
+
|
|
206
|
+
if (focusInput) {
|
|
207
|
+
focusTranslationInput();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasActiveSuggestion() {
|
|
212
|
+
const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
|
|
213
|
+
if (!resolved) return false;
|
|
214
|
+
|
|
215
|
+
if (getStaged(resolved.msgid, resolved.msgctxt)) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const suggestion = getSuggestion(resolved.msgid, resolved.msgctxt);
|
|
220
|
+
if (!suggestion) return false;
|
|
221
|
+
|
|
222
|
+
const originalValue = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
|
|
223
|
+
return translatedValue === suggestion && suggestion !== originalValue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function handleSelectAlt(event: Event, msgid: string, msgctxt: string | null) {
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
event.stopPropagation();
|
|
229
|
+
selectResolvedKey(msgid, msgctxt, true);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function moveSelection(direction: 1 | -1) {
|
|
233
|
+
const items = getCandidateList(contextResult);
|
|
234
|
+
if (!items.length) return false;
|
|
235
|
+
|
|
236
|
+
const current = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
|
|
237
|
+
const currentIndex = current
|
|
238
|
+
? items.findIndex(
|
|
239
|
+
(item) =>
|
|
240
|
+
item.msgid === current.msgid &&
|
|
241
|
+
(item.msgctxt ?? null) === (current.msgctxt ?? null)
|
|
242
|
+
)
|
|
243
|
+
: -1;
|
|
244
|
+
|
|
245
|
+
for (let step = 1; step <= items.length; step++) {
|
|
246
|
+
const nextIndex =
|
|
247
|
+
((Math.max(currentIndex, 0) + direction * step) % items.length + items.length) %
|
|
248
|
+
items.length;
|
|
249
|
+
const next = items[nextIndex];
|
|
250
|
+
const skipForKeyboard = next.hasTranslation && !next.isFuzzy;
|
|
251
|
+
|
|
252
|
+
if (skipForKeyboard) continue;
|
|
253
|
+
|
|
254
|
+
selectResolvedKey(next.msgid, next.msgctxt, true);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const nextIndex =
|
|
259
|
+
((Math.max(currentIndex, 0) + direction) % items.length + items.length) % items.length;
|
|
260
|
+
const next = items[nextIndex];
|
|
261
|
+
selectResolvedKey(next.msgid, next.msgctxt, true);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function onAltKeydown(event: KeyboardEvent, msgid: string, msgctxt: string | null) {
|
|
266
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
selectResolvedKey(msgid, msgctxt, true);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (event.key === "Tab") {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
moveSelection(event.shiftKey ? -1 : 1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function stageCurrentTranslation(event?: Event) {
|
|
279
|
+
event?.preventDefault();
|
|
280
|
+
|
|
281
|
+
const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
|
|
282
|
+
if (!resolved?.msgid) {
|
|
283
|
+
error = "No resolved translation key selected";
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const value = translatedValue.trim();
|
|
288
|
+
if (!value) {
|
|
289
|
+
error = "No translation";
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const originalValue = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
|
|
294
|
+
stagedTranslations = {
|
|
295
|
+
...stagedTranslations,
|
|
296
|
+
[translationKey(resolved.msgid, resolved.msgctxt)]: {
|
|
297
|
+
msgid: resolved.msgid,
|
|
298
|
+
msgctxt: resolved.msgctxt,
|
|
299
|
+
value,
|
|
300
|
+
origin: getEntryOrigin(contextResult, resolved.msgid, resolved.msgctxt),
|
|
301
|
+
originalValue,
|
|
302
|
+
isDirty: value !== originalValue
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
error = null;
|
|
307
|
+
success = "Staged";
|
|
308
|
+
|
|
309
|
+
if (!moveSelection(1)) {
|
|
310
|
+
focusTranslationInput();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function commitStagedTranslations() {
|
|
315
|
+
const payload = Object.values(stagedTranslations).filter((item) => item.isDirty);
|
|
316
|
+
if (!payload.length) {
|
|
317
|
+
error = "No changed translations to submit";
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
pending = true;
|
|
322
|
+
resetFeedback();
|
|
323
|
+
|
|
324
|
+
const res = await fetch(`${endpoint}?intent=commit-batch`, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: {
|
|
327
|
+
"content-type": "application/json"
|
|
328
|
+
},
|
|
329
|
+
body: JSON.stringify({
|
|
330
|
+
items: payload.map((item) => ({
|
|
331
|
+
resolvedMsgid: item.msgid,
|
|
332
|
+
resolvedMsgctxt: item.msgctxt,
|
|
333
|
+
translationValue: item.value
|
|
334
|
+
}))
|
|
335
|
+
})
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const json = await res.json();
|
|
339
|
+
pending = false;
|
|
340
|
+
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
error = json.error ?? "Batch commit failed";
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
success = json.message ?? "Translations committed";
|
|
347
|
+
|
|
348
|
+
const next = { ...stagedTranslations };
|
|
349
|
+
for (const item of payload) {
|
|
350
|
+
delete next[translationKey(item.msgid, item.msgctxt)];
|
|
351
|
+
}
|
|
352
|
+
stagedTranslations = next;
|
|
353
|
+
contextResult = applyCommittedTranslations(contextResult, payload);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function submitStagedTranslations(event: SubmitEvent) {
|
|
358
|
+
if (!event.defaultPrevented) {
|
|
359
|
+
event.preventDefault();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
await commitStagedTranslations();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function openTranslation(text: string) {
|
|
366
|
+
capturedSelection = text;
|
|
367
|
+
translatedValue = "";
|
|
368
|
+
resetFeedback();
|
|
369
|
+
resetContextState();
|
|
370
|
+
spawnTranslation = true;
|
|
371
|
+
showPendingChangesDialog = false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function fetchContext() {
|
|
375
|
+
if (!capturedSelection) return;
|
|
376
|
+
|
|
377
|
+
contextPending = true;
|
|
378
|
+
resetContextState();
|
|
379
|
+
resetFeedback();
|
|
380
|
+
|
|
381
|
+
const body = new FormData();
|
|
382
|
+
body.set("translationKey", capturedSelection);
|
|
383
|
+
body.set("currentPath", page.url.pathname);
|
|
384
|
+
|
|
385
|
+
const res = await fetch(`${endpoint}?intent=context`, {
|
|
386
|
+
method: "POST",
|
|
387
|
+
body
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const json = await res.json();
|
|
391
|
+
contextPending = false;
|
|
392
|
+
|
|
393
|
+
if (!res.ok) {
|
|
394
|
+
contextError = json.error ?? "Context lookup failed";
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
contextResult = json;
|
|
399
|
+
selectedResolvedKey = {
|
|
400
|
+
msgid: json.entry.msgid,
|
|
401
|
+
msgctxt: json.entry.msgctxt
|
|
402
|
+
};
|
|
403
|
+
translatedValue =
|
|
404
|
+
getStaged(json.entry.msgid, json.entry.msgctxt)?.value ||
|
|
405
|
+
getSuggestion(json.entry.msgid, json.entry.msgctxt) ||
|
|
406
|
+
json.entry.msgstr?.[0] ||
|
|
407
|
+
"";
|
|
408
|
+
|
|
409
|
+
if (!suggestionPending) {
|
|
410
|
+
suggestionPending = true;
|
|
411
|
+
void requestTranslationSuggestions(json, endpoint)
|
|
412
|
+
.then((nextSuggestions) => {
|
|
413
|
+
suggestionLookup = nextSuggestions;
|
|
414
|
+
|
|
415
|
+
const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
|
|
416
|
+
if (!resolved) return;
|
|
417
|
+
|
|
418
|
+
const staged = getStaged(resolved.msgid, resolved.msgctxt);
|
|
419
|
+
const original = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
|
|
420
|
+
const currentSuggestion = getSuggestion(resolved.msgid, resolved.msgctxt);
|
|
421
|
+
|
|
422
|
+
if (!staged && translatedValue === original && currentSuggestion) {
|
|
423
|
+
translatedValue = currentSuggestion;
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
.catch(() => {
|
|
427
|
+
// Suggestion lookup is best-effort in this dev tool.
|
|
428
|
+
})
|
|
429
|
+
.finally(() => {
|
|
430
|
+
suggestionPending = false;
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
focusTranslationInput();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function closeTranslation() {
|
|
438
|
+
spawnTranslation = false;
|
|
439
|
+
capturedSelection = undefined;
|
|
440
|
+
selectionStarted = false;
|
|
441
|
+
translatedValue = "";
|
|
442
|
+
selectedResolvedKey = null;
|
|
443
|
+
focusedAltKey = null;
|
|
444
|
+
showPendingChangesDialog = false;
|
|
445
|
+
resetFeedback();
|
|
446
|
+
resetContextState();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function requestCloseTranslation() {
|
|
450
|
+
if (pending) return;
|
|
451
|
+
|
|
452
|
+
if (hasDirtyStagedTranslations()) {
|
|
453
|
+
showPendingChangesDialog = true;
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
closeTranslation();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function discardAndCloseTranslation() {
|
|
461
|
+
stagedTranslations = {};
|
|
462
|
+
closeTranslation();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function submitAndCloseTranslation() {
|
|
466
|
+
const committed = await commitStagedTranslations();
|
|
467
|
+
if (committed) {
|
|
468
|
+
closeTranslation();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function getElementTranslationCandidate(target: EventTarget | null): string | null {
|
|
473
|
+
const el =
|
|
474
|
+
target instanceof Element
|
|
475
|
+
? target.closest(
|
|
476
|
+
'button, a, [role="button"], input[type="button"], input[type="submit"]'
|
|
477
|
+
)
|
|
478
|
+
: null;
|
|
479
|
+
|
|
480
|
+
if (!el) return null;
|
|
481
|
+
|
|
482
|
+
if (el instanceof HTMLInputElement) {
|
|
483
|
+
return (
|
|
484
|
+
el.value?.trim() ||
|
|
485
|
+
el.getAttribute("aria-label")?.trim() ||
|
|
486
|
+
el.title?.trim() ||
|
|
487
|
+
null
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return (
|
|
492
|
+
el.getAttribute("aria-label")?.trim() ||
|
|
493
|
+
el.textContent?.trim() ||
|
|
494
|
+
el.getAttribute("title")?.trim() ||
|
|
495
|
+
null
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function translationForm() {
|
|
500
|
+
if (!browser || !dev) return;
|
|
501
|
+
|
|
502
|
+
const onSelectStart = () => {
|
|
503
|
+
selectionStarted = dragQua;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
function onCaptureInteractivePointerDown(event: PointerEvent) {
|
|
507
|
+
if (!event.altKey) return;
|
|
508
|
+
|
|
509
|
+
const text = getElementTranslationCandidate(event.target);
|
|
510
|
+
if (!text) return;
|
|
511
|
+
|
|
512
|
+
event.preventDefault();
|
|
513
|
+
event.stopPropagation();
|
|
514
|
+
event.stopImmediatePropagation?.();
|
|
515
|
+
|
|
516
|
+
openTranslation(text);
|
|
517
|
+
void fetchContext();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const onMouseUp = () => {
|
|
521
|
+
if (!selectionStarted) return;
|
|
522
|
+
|
|
523
|
+
const text = document.getSelection()?.toString().trim();
|
|
524
|
+
if (text) {
|
|
525
|
+
openTranslation(text);
|
|
526
|
+
void fetchContext();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
selectionStarted = false;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const onDocumentMouseDown = (event: MouseEvent) => {
|
|
533
|
+
if (!spawnTranslation) return;
|
|
534
|
+
if (showPendingChangesDialog) return;
|
|
535
|
+
|
|
536
|
+
const target = event.target as Node | null;
|
|
537
|
+
if (panelEl && target && panelEl.contains(target)) return;
|
|
538
|
+
|
|
539
|
+
requestCloseTranslation();
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
function onCtrlDown(event: KeyboardEvent) {
|
|
543
|
+
dragQua = !event.ctrlKey;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function onCtrlUp() {
|
|
547
|
+
dragQua = true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
document.addEventListener("selectstart", onSelectStart);
|
|
551
|
+
document.addEventListener("mouseup", onMouseUp);
|
|
552
|
+
document.addEventListener("mousedown", onDocumentMouseDown);
|
|
553
|
+
document.addEventListener("keydown", onCtrlDown);
|
|
554
|
+
document.addEventListener("keyup", onCtrlUp);
|
|
555
|
+
document.addEventListener("pointerdown", onCaptureInteractivePointerDown, true);
|
|
556
|
+
|
|
557
|
+
return () => {
|
|
558
|
+
document.removeEventListener("selectstart", onSelectStart);
|
|
559
|
+
document.removeEventListener("mouseup", onMouseUp);
|
|
560
|
+
document.removeEventListener("mousedown", onDocumentMouseDown);
|
|
561
|
+
document.removeEventListener("keydown", onCtrlDown);
|
|
562
|
+
document.removeEventListener("keyup", onCtrlUp);
|
|
563
|
+
document.removeEventListener("pointerdown", onCaptureInteractivePointerDown, true);
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
onMount(translationForm);
|
|
568
|
+
</script>
|
|
569
|
+
|
|
570
|
+
<div class="translator-actions sticky-rotate">
|
|
571
|
+
<button
|
|
572
|
+
type="button"
|
|
573
|
+
class="translator-action-button"
|
|
574
|
+
onclick={rotateCatalogs}
|
|
575
|
+
disabled={rotatePending || pending}
|
|
576
|
+
aria-disabled={rotatePending || pending}
|
|
577
|
+
title="Rotate catalogs"
|
|
578
|
+
>
|
|
579
|
+
{rotatePending ? "Rotating..." : "Rotate"}
|
|
580
|
+
</button>
|
|
581
|
+
|
|
582
|
+
{#if success}
|
|
583
|
+
<div class="translator-feedback success">{success}</div>
|
|
584
|
+
{:else if error}
|
|
585
|
+
<div class="translator-feedback error">{error}</div>
|
|
586
|
+
{/if}
|
|
587
|
+
</div>
|
|
588
|
+
|
|
589
|
+
<div
|
|
590
|
+
class="translator-shell sticky"
|
|
591
|
+
bind:this={panelEl}
|
|
592
|
+
use:draggable={{
|
|
593
|
+
active: (activate: boolean) => {
|
|
594
|
+
activeSwitchLocale = activate;
|
|
595
|
+
},
|
|
596
|
+
drag: () => dragQua
|
|
597
|
+
}}
|
|
598
|
+
>
|
|
599
|
+
<div class="translator-toggle">
|
|
600
|
+
<button
|
|
601
|
+
type="button"
|
|
602
|
+
class="translator-toggle-button"
|
|
603
|
+
onclick={handleLocaleToggle}
|
|
604
|
+
title="qa-button"
|
|
605
|
+
disabled={activeSwitchLocale}
|
|
606
|
+
aria-disabled={activeSwitchLocale}
|
|
607
|
+
>
|
|
608
|
+
[-QA-] {renderedLocale}
|
|
609
|
+
</button>
|
|
610
|
+
</div>
|
|
611
|
+
|
|
612
|
+
{#if spawnTranslation}
|
|
613
|
+
<TranslationHelperForm
|
|
614
|
+
{capturedSelection}
|
|
615
|
+
{contextPending}
|
|
616
|
+
{contextError}
|
|
617
|
+
{contextResult}
|
|
618
|
+
{selectedResolvedKey}
|
|
619
|
+
{focusedAltKey}
|
|
620
|
+
bind:translatedValue
|
|
621
|
+
{stagedTranslations}
|
|
622
|
+
hasActiveSuggestion={hasActiveSuggestion()}
|
|
623
|
+
{pending}
|
|
624
|
+
{error}
|
|
625
|
+
{success}
|
|
626
|
+
onClose={requestCloseTranslation}
|
|
627
|
+
onSubmit={submitStagedTranslations}
|
|
628
|
+
onStage={stageCurrentTranslation}
|
|
629
|
+
onAltFocusChange={setFocusedAltKey}
|
|
630
|
+
{onAltKeydown}
|
|
631
|
+
onSelectAlt={handleSelectAlt}
|
|
632
|
+
{isStaged}
|
|
633
|
+
{isSelectedAlt}
|
|
634
|
+
{setTranslationInputEl}
|
|
635
|
+
{moveSelection}
|
|
636
|
+
/>
|
|
637
|
+
{/if}
|
|
638
|
+
|
|
639
|
+
{#if showPendingChangesDialog}
|
|
640
|
+
<PendingChangesDialog
|
|
641
|
+
{pending}
|
|
642
|
+
onCancel={() => (showPendingChangesDialog = false)}
|
|
643
|
+
onDiscard={discardAndCloseTranslation}
|
|
644
|
+
onSubmit={() => void submitAndCloseTranslation()}
|
|
645
|
+
/>
|
|
646
|
+
{/if}
|
|
647
|
+
</div>
|
|
648
|
+
|
|
649
|
+
<style>
|
|
650
|
+
.translator-shell {
|
|
651
|
+
display: flex;
|
|
652
|
+
flex-direction: column;
|
|
653
|
+
align-items: flex-start;
|
|
654
|
+
gap: 0.75rem;
|
|
655
|
+
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.translator-actions {
|
|
659
|
+
display: flex;
|
|
660
|
+
flex-direction: column;
|
|
661
|
+
align-items: flex-end;
|
|
662
|
+
gap: 0.4rem;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.translator-action-button {
|
|
666
|
+
appearance: none;
|
|
667
|
+
border: 1px solid rgba(255, 213, 128, 0.45);
|
|
668
|
+
background: linear-gradient(180deg, rgba(58, 42, 16, 0.96), rgba(39, 28, 10, 0.96));
|
|
669
|
+
color: #fff2d7;
|
|
670
|
+
border-radius: 999px;
|
|
671
|
+
padding: 0.45rem 0.8rem;
|
|
672
|
+
font: inherit;
|
|
673
|
+
font-size: 0.74rem;
|
|
674
|
+
font-weight: 700;
|
|
675
|
+
letter-spacing: 0.08em;
|
|
676
|
+
text-transform: uppercase;
|
|
677
|
+
cursor: pointer;
|
|
678
|
+
box-shadow: 0 10px 24px rgba(7, 10, 8, 0.22);
|
|
679
|
+
transition:
|
|
680
|
+
transform 0.16s ease,
|
|
681
|
+
box-shadow 0.16s ease,
|
|
682
|
+
opacity 0.16s ease,
|
|
683
|
+
border-color 0.16s ease;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.translator-action-button:hover:not(:disabled) {
|
|
687
|
+
transform: translateY(-1px);
|
|
688
|
+
box-shadow: 0 14px 28px rgba(7, 10, 8, 0.28);
|
|
689
|
+
border-color: rgba(255, 213, 128, 0.7);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.translator-action-button:disabled {
|
|
693
|
+
opacity: 0.45;
|
|
694
|
+
cursor: default;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.translator-feedback {
|
|
698
|
+
max-width: min(20rem, calc(100vw - 2rem));
|
|
699
|
+
padding: 0.45rem 0.7rem;
|
|
700
|
+
border-radius: 0.75rem;
|
|
701
|
+
font-size: 0.74rem;
|
|
702
|
+
line-height: 1.35;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
.translator-feedback.success {
|
|
706
|
+
background: rgba(70, 255, 170, 0.12);
|
|
707
|
+
border: 1px solid rgba(102, 255, 178, 0.28);
|
|
708
|
+
color: rgba(236, 255, 243, 0.98);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.translator-feedback.error {
|
|
712
|
+
background: rgba(255, 110, 110, 0.12);
|
|
713
|
+
border: 1px solid rgba(255, 130, 130, 0.28);
|
|
714
|
+
color: rgba(255, 224, 224, 0.98);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
.translator-toggle {
|
|
718
|
+
display: inline-flex;
|
|
719
|
+
opacity: 0.72;
|
|
720
|
+
transition: opacity 0.18s ease;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.translator-toggle-button {
|
|
724
|
+
appearance: none;
|
|
725
|
+
border: 1px solid rgba(145, 214, 176, 0.4);
|
|
726
|
+
background:
|
|
727
|
+
linear-gradient(180deg, rgba(33, 54, 43, 0.96), rgba(20, 30, 25, 0.96));
|
|
728
|
+
color: #e9fff1;
|
|
729
|
+
border-radius: 999px;
|
|
730
|
+
padding: 0.45rem 0.8rem;
|
|
731
|
+
font: inherit;
|
|
732
|
+
font-size: 0.78rem;
|
|
733
|
+
font-weight: 700;
|
|
734
|
+
letter-spacing: 0.08em;
|
|
735
|
+
text-transform: uppercase;
|
|
736
|
+
cursor: pointer;
|
|
737
|
+
box-shadow: 0 10px 24px rgba(7, 10, 8, 0.22);
|
|
738
|
+
transition:
|
|
739
|
+
transform 0.16s ease,
|
|
740
|
+
box-shadow 0.16s ease,
|
|
741
|
+
opacity 0.16s ease,
|
|
742
|
+
border-color 0.16s ease;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
.translator-toggle-button:hover:not(:disabled) {
|
|
746
|
+
transform: translateY(-1px);
|
|
747
|
+
box-shadow: 0 14px 28px rgba(7, 10, 8, 0.28);
|
|
748
|
+
border-color: rgba(145, 214, 176, 0.7);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.translator-toggle-button:disabled {
|
|
752
|
+
opacity: 0.38;
|
|
753
|
+
cursor: default;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.translator-toggle:hover {
|
|
757
|
+
opacity: 1;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
.sticky {
|
|
761
|
+
position: fixed;
|
|
762
|
+
top: 10%;
|
|
763
|
+
left: 5%;
|
|
764
|
+
z-index: 500000;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.sticky-rotate {
|
|
768
|
+
position: fixed;
|
|
769
|
+
right: 1rem;
|
|
770
|
+
bottom: 1rem;
|
|
771
|
+
z-index: 500000;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
@media (max-width: 640px) {
|
|
775
|
+
.sticky {
|
|
776
|
+
left: 0.75rem;
|
|
777
|
+
right: 0.75rem;
|
|
778
|
+
top: auto;
|
|
779
|
+
bottom: 5rem;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.sticky-rotate {
|
|
783
|
+
left: 0.75rem;
|
|
784
|
+
right: 0.75rem;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
</style>
|