@translifycc/angular 0.1.3 → 0.1.4
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/fesm2022/translifycc-angular.mjs +547 -0
- package/fesm2022/translifycc-angular.mjs.map +1 -0
- package/package.json +17 -7
- package/{dist/translify-selector.component.d.ts → types/translifycc-angular.d.ts} +21 -2
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -2
- package/dist/translate.d.ts +0 -15
- package/dist/translate.js +0 -100
- package/dist/translify-selector.component.js +0 -461
- package/dist/translify.module.d.ts +0 -2
- package/dist/translify.module.js +0 -19
- package/src/index.d.ts +0 -9
- package/src/index.ts +0 -3
- package/src/translate.ts +0 -112
- package/src/translify-selector.component.ts +0 -463
- package/src/translify.module.ts +0 -10
- package/tsconfig.json +0 -18
package/src/index.d.ts
DELETED
package/src/index.ts
DELETED
package/src/translate.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
export interface Language {
|
|
2
|
-
code: string;
|
|
3
|
-
name: string;
|
|
4
|
-
nativeName: string;
|
|
5
|
-
isRTL: boolean;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const SKIP_TAGS = new Set([
|
|
9
|
-
'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME',
|
|
10
|
-
'CODE', 'PRE', 'SVG', 'MATH', 'INPUT', 'TEXTAREA',
|
|
11
|
-
]);
|
|
12
|
-
|
|
13
|
-
export function getSavedLang(): string | null {
|
|
14
|
-
return localStorage.getItem('translify_lang');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function saveLang(lang: string): void {
|
|
18
|
-
localStorage.setItem('translify_lang', lang);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function clearSavedLang(): void {
|
|
22
|
-
localStorage.removeItem('translify_lang');
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function loadTranslationCache(lang: string): Map<string, string> {
|
|
26
|
-
try {
|
|
27
|
-
const stored = localStorage.getItem(`translify_cache_${lang}`);
|
|
28
|
-
return stored ? new Map(Object.entries(JSON.parse(stored))) : new Map();
|
|
29
|
-
} catch {
|
|
30
|
-
return new Map();
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function saveTranslationCache(lang: string, cache: Map<string, string>): void {
|
|
35
|
-
try {
|
|
36
|
-
const obj: Record<string, string> = {};
|
|
37
|
-
cache.forEach((v, k) => { obj[k] = v; });
|
|
38
|
-
localStorage.setItem(`translify_cache_${lang}`, JSON.stringify(obj));
|
|
39
|
-
} catch {}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function getTextNodes(root: Node): Text[] {
|
|
43
|
-
const nodes: Text[] = [];
|
|
44
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
45
|
-
acceptNode(node: Node) {
|
|
46
|
-
const text = (node as Text);
|
|
47
|
-
if (!text.textContent?.trim()) return NodeFilter.FILTER_REJECT;
|
|
48
|
-
if (SKIP_TAGS.has((text.parentElement?.tagName ?? ''))) return NodeFilter.FILTER_REJECT;
|
|
49
|
-
if ((text.textContent?.trim().length ?? 0) > 5000) return NodeFilter.FILTER_REJECT;
|
|
50
|
-
if (text.parentElement?.closest('[translate="no"]')) return NodeFilter.FILTER_REJECT;
|
|
51
|
-
return NodeFilter.FILTER_ACCEPT;
|
|
52
|
-
},
|
|
53
|
-
});
|
|
54
|
-
let n: Node | null;
|
|
55
|
-
while ((n = walker.nextNode())) nodes.push(n as Text);
|
|
56
|
-
return nodes;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function collectNewTextNodes(node: Node, originalTexts: Map<Node, string>): Text[] {
|
|
60
|
-
const nodes: Text[] = [];
|
|
61
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
62
|
-
const t = node as Text;
|
|
63
|
-
if (!originalTexts.has(t) && t.textContent?.trim()) nodes.push(t);
|
|
64
|
-
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
65
|
-
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
|
66
|
-
let n: Node | null;
|
|
67
|
-
while ((n = walker.nextNode())) {
|
|
68
|
-
const t = n as Text;
|
|
69
|
-
if (
|
|
70
|
-
!originalTexts.has(t) &&
|
|
71
|
-
t.textContent?.trim() &&
|
|
72
|
-
!SKIP_TAGS.has(t.parentElement?.tagName ?? '') &&
|
|
73
|
-
!t.parentElement?.closest('[translate="no"]')
|
|
74
|
-
) {
|
|
75
|
-
nodes.push(t);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return nodes;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export async function fetchLanguages(apiBase: string): Promise<Language[]> {
|
|
83
|
-
const res = await fetch(`${apiBase}/translate/languages`);
|
|
84
|
-
const json = await res.json();
|
|
85
|
-
return json.data.languages;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function callTranslateApi(
|
|
89
|
-
apiBase: string,
|
|
90
|
-
apiKey: string,
|
|
91
|
-
domain: string,
|
|
92
|
-
texts: string[],
|
|
93
|
-
targetLang: string,
|
|
94
|
-
): Promise<string[]> {
|
|
95
|
-
const results: string[] = [];
|
|
96
|
-
for (let i = 0; i < texts.length; i += 100) {
|
|
97
|
-
const chunk = texts.slice(i, i + 100);
|
|
98
|
-
const res = await fetch(`${apiBase}/translate`, {
|
|
99
|
-
method: 'POST',
|
|
100
|
-
headers: {
|
|
101
|
-
'Content-Type': 'application/json',
|
|
102
|
-
'x-api-key': apiKey,
|
|
103
|
-
'x-domain': domain,
|
|
104
|
-
},
|
|
105
|
-
body: JSON.stringify({ texts: chunk, targetLanguage: targetLang, sourceLanguage: 'auto' }),
|
|
106
|
-
});
|
|
107
|
-
const json = await res.json();
|
|
108
|
-
if (!json.success) throw new Error(json.message || 'Translation failed');
|
|
109
|
-
results.push(...json.data.translations);
|
|
110
|
-
}
|
|
111
|
-
return results;
|
|
112
|
-
}
|
|
@@ -1,463 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Component,
|
|
3
|
-
Input,
|
|
4
|
-
OnInit,
|
|
5
|
-
OnDestroy,
|
|
6
|
-
NgZone,
|
|
7
|
-
ChangeDetectionStrategy,
|
|
8
|
-
ChangeDetectorRef,
|
|
9
|
-
} from '@angular/core';
|
|
10
|
-
import { NgIf, NgFor, NgClass } from '@angular/common';
|
|
11
|
-
import {
|
|
12
|
-
Language,
|
|
13
|
-
getSavedLang,
|
|
14
|
-
saveLang,
|
|
15
|
-
clearSavedLang,
|
|
16
|
-
loadTranslationCache,
|
|
17
|
-
saveTranslationCache,
|
|
18
|
-
getTextNodes,
|
|
19
|
-
collectNewTextNodes,
|
|
20
|
-
fetchLanguages,
|
|
21
|
-
callTranslateApi,
|
|
22
|
-
} from './translate';
|
|
23
|
-
|
|
24
|
-
@Component({
|
|
25
|
-
selector: 'translify-selector',
|
|
26
|
-
standalone: true,
|
|
27
|
-
imports: [NgIf, NgFor, NgClass],
|
|
28
|
-
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
29
|
-
styles: [`
|
|
30
|
-
.translify-selector { position: relative; display: inline-block; }
|
|
31
|
-
.translify-trigger {
|
|
32
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
33
|
-
padding: 8px 14px; border-radius: 8px; border: 1px solid #e5e7eb;
|
|
34
|
-
background: #fff; cursor: pointer; font-size: 14px; color: #374151;
|
|
35
|
-
transition: border-color 0.15s;
|
|
36
|
-
}
|
|
37
|
-
.translify-trigger:hover { border-color: #9ca3af; }
|
|
38
|
-
.translify-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
39
|
-
.translify-trigger-chevron { font-size: 10px; transition: transform 0.2s; }
|
|
40
|
-
.translify-trigger-chevron.open { transform: rotate(180deg); }
|
|
41
|
-
.translify-dropdown {
|
|
42
|
-
position: absolute; top: calc(100% + 6px); left: 0; z-index: 9999;
|
|
43
|
-
background: #fff; border: 1px solid #e5e7eb; border-radius: 12px;
|
|
44
|
-
min-width: 220px; max-height: 320px; overflow-y: auto;
|
|
45
|
-
box-shadow: 0 8px 24px rgba(0,0,0,0.12); display: flex; flex-direction: column;
|
|
46
|
-
}
|
|
47
|
-
.translify-dropdown-header {
|
|
48
|
-
padding: 10px 14px; font-size: 11px; font-weight: 700;
|
|
49
|
-
color: #6b7280; text-transform: uppercase; letter-spacing: 0.06em;
|
|
50
|
-
border-bottom: 1px solid #f3f4f6; flex-shrink: 0;
|
|
51
|
-
}
|
|
52
|
-
.translify-option {
|
|
53
|
-
display: flex; align-items: center; gap: 8px;
|
|
54
|
-
padding: 10px 14px; cursor: pointer; font-size: 14px;
|
|
55
|
-
color: #111827; border: none; background: none; width: 100%; text-align: left;
|
|
56
|
-
transition: background 0.1s;
|
|
57
|
-
}
|
|
58
|
-
.translify-option:hover { background: #f9fafb; }
|
|
59
|
-
.translify-option.active { background: #eff6ff; color: #2563eb; font-weight: 600; }
|
|
60
|
-
.translify-option-name { font-size: 12px; color: #9ca3af; margin-left: auto; }
|
|
61
|
-
.translify-option-rtl {
|
|
62
|
-
font-size: 10px; color: #9ca3af;
|
|
63
|
-
background: #f3f4f6; padding: 2px 5px; border-radius: 4px;
|
|
64
|
-
}
|
|
65
|
-
.translify-restore {
|
|
66
|
-
display: flex; align-items: center; gap: 6px;
|
|
67
|
-
padding: 10px 14px; cursor: pointer; font-size: 13px; color: #ef4444;
|
|
68
|
-
border: none; background: none; width: 100%; text-align: left;
|
|
69
|
-
border-top: 1px solid #f3f4f6; flex-shrink: 0; transition: background 0.1s;
|
|
70
|
-
}
|
|
71
|
-
.translify-restore:hover { background: #fff5f5; }
|
|
72
|
-
.translify-toast {
|
|
73
|
-
position: fixed; bottom: 84px; right: 24px; z-index: 2147483647;
|
|
74
|
-
background: #1e293b; color: #fff; padding: 10px 16px;
|
|
75
|
-
border-radius: 10px; font-size: 13px; pointer-events: none;
|
|
76
|
-
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
77
|
-
}
|
|
78
|
-
.translify-overlay {
|
|
79
|
-
position: fixed; inset: 0; z-index: 2147483646;
|
|
80
|
-
background: rgba(255,255,255,0.85); backdrop-filter: blur(3px);
|
|
81
|
-
display: flex; flex-direction: column;
|
|
82
|
-
align-items: center; justify-content: center; gap: 14px;
|
|
83
|
-
transition: opacity 0.35s ease;
|
|
84
|
-
}
|
|
85
|
-
.translify-overlay-spinner {
|
|
86
|
-
width: 40px; height: 40px; border-radius: 50%;
|
|
87
|
-
border: 3px solid #e5e7eb; border-top-color: #2563eb;
|
|
88
|
-
animation: tlfy-spin 0.7s linear infinite;
|
|
89
|
-
}
|
|
90
|
-
@keyframes tlfy-spin { to { transform: rotate(360deg); } }
|
|
91
|
-
.translify-overlay-title { font-size: 15px; font-weight: 600; color: #111827; text-align: center; }
|
|
92
|
-
.translify-overlay-sub { font-size: 12px; color: #6b7280; margin-top: 4px; text-align: center; }
|
|
93
|
-
`],
|
|
94
|
-
template: `
|
|
95
|
-
<div class="translify-selector" (clickOutside)="isOpen = false">
|
|
96
|
-
<button
|
|
97
|
-
class="translify-trigger"
|
|
98
|
-
[class]="classNames.trigger || ''"
|
|
99
|
-
(click)="isOpen = !isOpen"
|
|
100
|
-
[disabled]="loadingState !== 'idle' || languages.length === 0"
|
|
101
|
-
[attr.aria-expanded]="isOpen"
|
|
102
|
-
>
|
|
103
|
-
<span>🌐</span>
|
|
104
|
-
<span>{{ activeLangObj ? activeLangObj.nativeName : (labels.trigger || 'Translate') }}</span>
|
|
105
|
-
<span class="translify-trigger-chevron" [class.open]="isOpen">▾</span>
|
|
106
|
-
</button>
|
|
107
|
-
|
|
108
|
-
<div *ngIf="isOpen" class="translify-dropdown" [class]="classNames.dropdown || ''">
|
|
109
|
-
<div class="translify-dropdown-header">{{ labels.dropdownHeader || 'Select Language' }}</div>
|
|
110
|
-
|
|
111
|
-
<button
|
|
112
|
-
*ngFor="let lang of languages"
|
|
113
|
-
class="translify-option"
|
|
114
|
-
[class.active]="activeLang === lang.code"
|
|
115
|
-
(click)="handleSelect(lang.code)"
|
|
116
|
-
>
|
|
117
|
-
<span>{{ lang.nativeName }}</span>
|
|
118
|
-
<span class="translify-option-name">{{ lang.name }}</span>
|
|
119
|
-
<span *ngIf="lang.isRTL" class="translify-option-rtl">RTL</span>
|
|
120
|
-
</button>
|
|
121
|
-
|
|
122
|
-
<button *ngIf="activeLang" class="translify-restore" (click)="restore()">
|
|
123
|
-
↩ {{ labels.restore || 'Show original' }}
|
|
124
|
-
</button>
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
|
|
128
|
-
<div
|
|
129
|
-
*ngIf="loadingState === 'overlay'"
|
|
130
|
-
class="translify-overlay"
|
|
131
|
-
[style.opacity]="overlayVisible ? '1' : '0'"
|
|
132
|
-
>
|
|
133
|
-
<div class="translify-overlay-spinner"></div>
|
|
134
|
-
<div>
|
|
135
|
-
<div class="translify-overlay-title">{{ labels.overlayTitle || 'Translating page…' }}</div>
|
|
136
|
-
<div class="translify-overlay-sub">{{ labels.overlaySub || 'First time only — instant on next visit.' }}</div>
|
|
137
|
-
</div>
|
|
138
|
-
</div>
|
|
139
|
-
|
|
140
|
-
<div
|
|
141
|
-
*ngIf="loadingState === 'toast'"
|
|
142
|
-
class="translify-toast"
|
|
143
|
-
[style.opacity]="toastVisible ? '1' : '0'"
|
|
144
|
-
[style.transform]="toastVisible ? 'translateY(0)' : 'translateY(8px)'"
|
|
145
|
-
>
|
|
146
|
-
⏳ Translating…
|
|
147
|
-
</div>
|
|
148
|
-
`,
|
|
149
|
-
})
|
|
150
|
-
export class TranslifySelectorComponent implements OnInit, OnDestroy {
|
|
151
|
-
@Input() apiKey!: string;
|
|
152
|
-
@Input() apiBase = 'https://translify.cc';
|
|
153
|
-
@Input() domain = window.location.hostname || 'localhost';
|
|
154
|
-
@Input() classNames: {
|
|
155
|
-
trigger?: string; dropdown?: string; dropdownHeader?: string;
|
|
156
|
-
option?: string; optionActive?: string; optionNative?: string;
|
|
157
|
-
optionName?: string; optionRtl?: string; restore?: string;
|
|
158
|
-
overlay?: string; overlaySpinner?: string; overlayTitle?: string;
|
|
159
|
-
overlaySub?: string; toast?: string;
|
|
160
|
-
} = {};
|
|
161
|
-
@Input() labels: {
|
|
162
|
-
trigger?: string; dropdownHeader?: string; restore?: string;
|
|
163
|
-
overlayTitle?: string; overlaySub?: string;
|
|
164
|
-
} = {};
|
|
165
|
-
|
|
166
|
-
languages: Language[] = [];
|
|
167
|
-
activeLang: string | null = getSavedLang();
|
|
168
|
-
isOpen = false;
|
|
169
|
-
loadingState: 'idle' | 'overlay' | 'toast' = 'idle';
|
|
170
|
-
overlayVisible = false;
|
|
171
|
-
toastVisible = false;
|
|
172
|
-
|
|
173
|
-
// All mutable translation state — no change detection needed
|
|
174
|
-
private originalTexts = new Map<Node, string>();
|
|
175
|
-
private translationCache = new Map<string, string>();
|
|
176
|
-
private currentCacheLang: string | null = null;
|
|
177
|
-
private isTranslating = false;
|
|
178
|
-
private pendingNodes: Text[] = [];
|
|
179
|
-
private observer: MutationObserver | null = null;
|
|
180
|
-
private debounceTimer: any = null;
|
|
181
|
-
private overlayTimer: any = null;
|
|
182
|
-
|
|
183
|
-
constructor(private ngZone: NgZone, private cdr: ChangeDetectorRef) {}
|
|
184
|
-
|
|
185
|
-
get activeLangObj(): Language | undefined {
|
|
186
|
-
return this.languages.find((l) => l.code === this.activeLang);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
async ngOnInit() {
|
|
190
|
-
if (!this.apiKey) { console.error('[Translify] apiKey is required.'); return; }
|
|
191
|
-
|
|
192
|
-
try {
|
|
193
|
-
this.languages = await fetchLanguages(this.apiBase);
|
|
194
|
-
this.cdr.markForCheck();
|
|
195
|
-
} catch (e) {
|
|
196
|
-
console.error('[Translify] Failed to fetch languages', e);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
this.ngZone.runOutsideAngular(() => {
|
|
201
|
-
this.startObserver();
|
|
202
|
-
this.patchHistory();
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
if (this.activeLang && this.languages.length > 0) {
|
|
206
|
-
await this.translate(this.activeLang);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
ngOnDestroy() {
|
|
211
|
-
this.stopObserver();
|
|
212
|
-
clearTimeout(this.overlayTimer);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ── Cache helpers ───────────────────────────────────────────────────────────
|
|
216
|
-
|
|
217
|
-
private ensureCacheLoaded(lang: string) {
|
|
218
|
-
if (this.currentCacheLang === lang) return;
|
|
219
|
-
this.translationCache = loadTranslationCache(lang);
|
|
220
|
-
this.currentCacheLang = lang;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
private applyCache(nodes: Text[]): Text[] {
|
|
224
|
-
const uncached: Text[] = [];
|
|
225
|
-
nodes.forEach((node) => {
|
|
226
|
-
const src = this.originalTexts.get(node) ?? node.textContent ?? '';
|
|
227
|
-
const hit = this.translationCache.get(src);
|
|
228
|
-
if (hit !== undefined) {
|
|
229
|
-
node.textContent = hit;
|
|
230
|
-
} else {
|
|
231
|
-
uncached.push(node);
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
return uncached;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── Overlay / toast ─────────────────────────────────────────────────────────
|
|
238
|
-
|
|
239
|
-
private showOverlay() {
|
|
240
|
-
clearTimeout(this.overlayTimer);
|
|
241
|
-
this.ngZone.run(() => {
|
|
242
|
-
this.overlayVisible = false;
|
|
243
|
-
this.loadingState = 'overlay';
|
|
244
|
-
requestAnimationFrame(() => { this.overlayVisible = true; this.cdr.markForCheck(); });
|
|
245
|
-
this.cdr.markForCheck();
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
private hideOverlay() {
|
|
250
|
-
this.overlayTimer = setTimeout(() => {
|
|
251
|
-
this.ngZone.run(() => { this.overlayVisible = false; this.cdr.markForCheck(); });
|
|
252
|
-
this.overlayTimer = setTimeout(() => {
|
|
253
|
-
this.ngZone.run(() => { this.loadingState = 'idle'; this.cdr.markForCheck(); });
|
|
254
|
-
}, 400);
|
|
255
|
-
}, 600);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
private showToast() {
|
|
259
|
-
clearTimeout(this.overlayTimer);
|
|
260
|
-
this.ngZone.run(() => {
|
|
261
|
-
this.loadingState = 'toast';
|
|
262
|
-
requestAnimationFrame(() => { this.toastVisible = true; this.cdr.markForCheck(); });
|
|
263
|
-
this.cdr.markForCheck();
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
private hideToast() {
|
|
268
|
-
this.ngZone.run(() => {
|
|
269
|
-
this.toastVisible = false;
|
|
270
|
-
this.overlayTimer = setTimeout(() => {
|
|
271
|
-
this.ngZone.run(() => { this.loadingState = 'idle'; this.cdr.markForCheck(); });
|
|
272
|
-
}, 350);
|
|
273
|
-
this.cdr.markForCheck();
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// ── flushNodes ──────────────────────────────────────────────────────────────
|
|
278
|
-
|
|
279
|
-
private async flushNodes(nodes: Text[], targetLang: string) {
|
|
280
|
-
if (!nodes.length) return;
|
|
281
|
-
if (this.isTranslating) { this.pendingNodes.push(...nodes); return; }
|
|
282
|
-
this.isTranslating = true;
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
nodes.forEach((n) => {
|
|
286
|
-
if (!this.originalTexts.has(n)) this.originalTexts.set(n, n.textContent ?? '');
|
|
287
|
-
});
|
|
288
|
-
const uncached = this.applyCache(nodes);
|
|
289
|
-
if (uncached.length > 0) {
|
|
290
|
-
const texts = uncached.map((n) => this.originalTexts.get(n)!);
|
|
291
|
-
const translations = await callTranslateApi(this.apiBase, this.apiKey, this.domain, texts, targetLang);
|
|
292
|
-
uncached.forEach((node, i) => {
|
|
293
|
-
if (translations[i] !== undefined) {
|
|
294
|
-
this.translationCache.set(this.originalTexts.get(node)!, translations[i]);
|
|
295
|
-
node.textContent = translations[i];
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
saveTranslationCache(targetLang, this.translationCache);
|
|
299
|
-
}
|
|
300
|
-
} catch (e) {
|
|
301
|
-
console.error('[Translify] Dynamic translation error', e);
|
|
302
|
-
} finally {
|
|
303
|
-
this.isTranslating = false;
|
|
304
|
-
if (this.pendingNodes.length > 0) {
|
|
305
|
-
const next = this.pendingNodes.splice(0);
|
|
306
|
-
this.flushNodes(next, targetLang);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// ── MutationObserver ────────────────────────────────────────────────────────
|
|
312
|
-
|
|
313
|
-
private startObserver() {
|
|
314
|
-
if (this.observer) return;
|
|
315
|
-
|
|
316
|
-
this.observer = new MutationObserver((mutations) => {
|
|
317
|
-
if (!this.activeLang) return;
|
|
318
|
-
const targetLang = this.activeLang;
|
|
319
|
-
|
|
320
|
-
const newNodes: Text[] = [];
|
|
321
|
-
mutations.forEach((mutation) => {
|
|
322
|
-
if (mutation.type !== 'childList') return;
|
|
323
|
-
mutation.addedNodes.forEach((node) => {
|
|
324
|
-
newNodes.push(...collectNewTextNodes(node, this.originalTexts));
|
|
325
|
-
});
|
|
326
|
-
});
|
|
327
|
-
if (!newNodes.length) return;
|
|
328
|
-
|
|
329
|
-
const uncached: Text[] = [];
|
|
330
|
-
newNodes.forEach((node) => {
|
|
331
|
-
const src = node.textContent ?? '';
|
|
332
|
-
const hit = this.translationCache.get(src);
|
|
333
|
-
if (hit !== undefined) {
|
|
334
|
-
this.originalTexts.set(node, src);
|
|
335
|
-
node.textContent = hit;
|
|
336
|
-
} else {
|
|
337
|
-
uncached.push(node);
|
|
338
|
-
}
|
|
339
|
-
});
|
|
340
|
-
if (!uncached.length) return;
|
|
341
|
-
|
|
342
|
-
this.pendingNodes.push(...uncached);
|
|
343
|
-
if (!this.isTranslating) {
|
|
344
|
-
clearTimeout(this.debounceTimer);
|
|
345
|
-
this.debounceTimer = setTimeout(() => {
|
|
346
|
-
const toFlush = this.pendingNodes.splice(0);
|
|
347
|
-
this.flushNodes(toFlush, targetLang);
|
|
348
|
-
}, 50);
|
|
349
|
-
}
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
this.observer.observe(document.body, { childList: true, subtree: true });
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
private stopObserver() {
|
|
356
|
-
if (this.observer) { this.observer.disconnect(); this.observer = null; }
|
|
357
|
-
clearTimeout(this.debounceTimer);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// ── SPA route change detection ──────────────────────────────────────────────
|
|
361
|
-
|
|
362
|
-
private patchHistory() {
|
|
363
|
-
const orig = history.pushState.bind(history);
|
|
364
|
-
const self = this;
|
|
365
|
-
history.pushState = function(state: unknown, unused: string, url?: string | URL | null) {
|
|
366
|
-
orig(state, unused, url);
|
|
367
|
-
self.onRouteChange();
|
|
368
|
-
};
|
|
369
|
-
window.addEventListener('popstate', () => this.onRouteChange());
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
private onRouteChange() {
|
|
373
|
-
if (!this.activeLang) return;
|
|
374
|
-
const targetLang = this.activeLang;
|
|
375
|
-
setTimeout(() => {
|
|
376
|
-
const nodes = getTextNodes(document.body).filter((n) => !this.originalTexts.has(n));
|
|
377
|
-
if (!nodes.length) return;
|
|
378
|
-
this.pendingNodes.push(...nodes);
|
|
379
|
-
if (!this.isTranslating) {
|
|
380
|
-
const toFlush = this.pendingNodes.splice(0);
|
|
381
|
-
this.flushNodes(toFlush, targetLang);
|
|
382
|
-
}
|
|
383
|
-
}, 100);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// ── Main translate ──────────────────────────────────────────────────────────
|
|
387
|
-
|
|
388
|
-
async translate(targetLang: string) {
|
|
389
|
-
if (this.isTranslating) return;
|
|
390
|
-
this.isTranslating = true;
|
|
391
|
-
|
|
392
|
-
try {
|
|
393
|
-
this.ensureCacheLoaded(targetLang);
|
|
394
|
-
const isFirstEver = this.translationCache.size === 0;
|
|
395
|
-
|
|
396
|
-
if (this.originalTexts.size > 0) {
|
|
397
|
-
this.originalTexts.forEach((text, node) => { node.textContent = text; });
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const nodes = getTextNodes(document.body);
|
|
401
|
-
if (!nodes.length) return;
|
|
402
|
-
|
|
403
|
-
nodes.forEach((node) => {
|
|
404
|
-
if (!this.originalTexts.has(node)) this.originalTexts.set(node, node.textContent ?? '');
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
const uncached = this.applyCache(nodes);
|
|
408
|
-
|
|
409
|
-
const lang = this.languages.find((l) => l.code === targetLang);
|
|
410
|
-
document.documentElement.setAttribute('dir', lang?.isRTL ? 'rtl' : 'ltr');
|
|
411
|
-
|
|
412
|
-
saveLang(targetLang);
|
|
413
|
-
this.ngZone.run(() => { this.activeLang = targetLang; this.cdr.markForCheck(); });
|
|
414
|
-
|
|
415
|
-
if (uncached.length > 0) {
|
|
416
|
-
if (isFirstEver) this.showOverlay(); else this.showToast();
|
|
417
|
-
|
|
418
|
-
const texts = uncached.map((n) => this.originalTexts.get(n)!);
|
|
419
|
-
const translations = await callTranslateApi(this.apiBase, this.apiKey, this.domain, texts, targetLang);
|
|
420
|
-
|
|
421
|
-
uncached.forEach((node, i) => {
|
|
422
|
-
if (translations[i] !== undefined) {
|
|
423
|
-
this.translationCache.set(this.originalTexts.get(node)!, translations[i]);
|
|
424
|
-
node.textContent = translations[i];
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
saveTranslationCache(targetLang, this.translationCache);
|
|
429
|
-
if (isFirstEver) this.hideOverlay(); else this.hideToast();
|
|
430
|
-
}
|
|
431
|
-
} catch (e) {
|
|
432
|
-
console.error('[Translify] Translation error', e);
|
|
433
|
-
this.hideOverlay(); this.hideToast();
|
|
434
|
-
} finally {
|
|
435
|
-
this.isTranslating = false;
|
|
436
|
-
if (this.pendingNodes.length > 0) {
|
|
437
|
-
const next = this.pendingNodes.splice(0);
|
|
438
|
-
this.flushNodes(next, this.activeLang!);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// ── Restore ─────────────────────────────────────────────────────────────────
|
|
444
|
-
|
|
445
|
-
restore() {
|
|
446
|
-
this.stopObserver();
|
|
447
|
-
this.originalTexts.forEach((text, node) => { node.textContent = text; });
|
|
448
|
-
this.originalTexts.clear();
|
|
449
|
-
document.documentElement.setAttribute('dir', 'ltr');
|
|
450
|
-
clearSavedLang();
|
|
451
|
-
this.activeLang = null;
|
|
452
|
-
this.isOpen = false;
|
|
453
|
-
this.cdr.markForCheck();
|
|
454
|
-
this.ngZone.runOutsideAngular(() => this.startObserver());
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// ── UI handlers ─────────────────────────────────────────────────────────────
|
|
458
|
-
|
|
459
|
-
handleSelect(langCode: string) {
|
|
460
|
-
this.isOpen = false;
|
|
461
|
-
this.translate(langCode);
|
|
462
|
-
}
|
|
463
|
-
}
|
package/src/translify.module.ts
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import { NgModule } from '@angular/core';
|
|
2
|
-
import { TranslifySelectorComponent } from './translify-selector.component';
|
|
3
|
-
|
|
4
|
-
// For NgModule-based apps (Angular 15+)
|
|
5
|
-
// Standalone component apps can import TranslifySelectorComponent directly
|
|
6
|
-
@NgModule({
|
|
7
|
-
imports: [TranslifySelectorComponent],
|
|
8
|
-
exports: [TranslifySelectorComponent],
|
|
9
|
-
})
|
|
10
|
-
export class TranslifyModule {}
|
package/tsconfig.json
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2017",
|
|
4
|
-
"module": "ES2020",
|
|
5
|
-
"moduleResolution": "node",
|
|
6
|
-
"declaration": true,
|
|
7
|
-
"declarationMap": false,
|
|
8
|
-
"sourceMap": false,
|
|
9
|
-
"outDir": "./dist",
|
|
10
|
-
"strict": false,
|
|
11
|
-
"experimentalDecorators": true,
|
|
12
|
-
"emitDecoratorMetadata": true,
|
|
13
|
-
"skipLibCheck": true,
|
|
14
|
-
"lib": ["ES2017", "DOM"]
|
|
15
|
-
},
|
|
16
|
-
"include": ["src/**/*.ts"],
|
|
17
|
-
"exclude": ["node_modules"]
|
|
18
|
-
}
|