@translifycc/angular 0.1.3 → 0.1.5
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/README.md +141 -0
- 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/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# @translifycc/angular
|
|
2
|
+
|
|
3
|
+
Angular language selector component for [Translify](https://translify.cc) — add AI-powered translations to any Angular app in minutes. Compatible with Angular 15–19.
|
|
4
|
+
|
|
5
|
+
## Get your API key
|
|
6
|
+
|
|
7
|
+
Sign up free at **[translify.cc/signup](https://translify.cc/signup)** — no credit card required.
|
|
8
|
+
|
|
9
|
+
**Free tier:** 100,000 unique characters/month. Cached translations are always free and never count against your limit.
|
|
10
|
+
|
|
11
|
+
| Plan | Price | Unique chars/month |
|
|
12
|
+
|---------|--------|--------------------|
|
|
13
|
+
| Free | $0 | 100,000 |
|
|
14
|
+
| Starter | $25/mo | 5,000,000 |
|
|
15
|
+
| Growth | $49/mo | 25,000,000 |
|
|
16
|
+
| Pro | $99/mo | 100,000,000 |
|
|
17
|
+
|
|
18
|
+
> **How billing works:** You're only charged for characters that hit OpenAI (first-time translations). Repeat translations served from cache are always free.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install @translifycc/angular
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### Standalone component (Angular 15+)
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// app.component.ts
|
|
34
|
+
import { Component } from '@angular/core';
|
|
35
|
+
import { TranslifySelectorComponent } from '@translifycc/angular';
|
|
36
|
+
|
|
37
|
+
@Component({
|
|
38
|
+
standalone: true,
|
|
39
|
+
imports: [TranslifySelectorComponent],
|
|
40
|
+
template: `
|
|
41
|
+
<nav>
|
|
42
|
+
<translify-selector apiKey="YOUR_API_KEY" />
|
|
43
|
+
</nav>
|
|
44
|
+
`
|
|
45
|
+
})
|
|
46
|
+
export class AppComponent {}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### NgModule-based app
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// app.module.ts
|
|
53
|
+
import { NgModule } from '@angular/core';
|
|
54
|
+
import { TranslifyModule } from '@translifycc/angular';
|
|
55
|
+
|
|
56
|
+
@NgModule({
|
|
57
|
+
imports: [TranslifyModule],
|
|
58
|
+
})
|
|
59
|
+
export class AppModule {}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```html
|
|
63
|
+
<!-- any template -->
|
|
64
|
+
<translify-selector apiKey="YOUR_API_KEY" domain="myapp.com" />
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Inputs
|
|
70
|
+
|
|
71
|
+
| Input | Type | Default | Description |
|
|
72
|
+
|--------------|--------|----------------------------|--------------------------------------|
|
|
73
|
+
| `apiKey` | string | **required** | Your Translify API key |
|
|
74
|
+
| `apiBase` | string | `https://translify.cc` | API base URL |
|
|
75
|
+
| `domain` | string | `window.location.hostname` | Your site domain |
|
|
76
|
+
| `classNames` | object | `{}` | Override class names for any element |
|
|
77
|
+
| `labels` | object | `{}` | Override UI text |
|
|
78
|
+
|
|
79
|
+
### `classNames` keys
|
|
80
|
+
|
|
81
|
+
`trigger` · `dropdown` · `dropdownHeader` · `option` · `optionActive` · `optionNative` · `optionName` · `optionRtl` · `restore` · `overlay` · `overlaySpinner` · `overlayTitle` · `overlaySub` · `toast`
|
|
82
|
+
|
|
83
|
+
### `labels` keys
|
|
84
|
+
|
|
85
|
+
`trigger` · `dropdownHeader` · `restore` · `overlayTitle` · `overlaySub`
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Customisation
|
|
90
|
+
|
|
91
|
+
```html
|
|
92
|
+
<translify-selector
|
|
93
|
+
apiKey="YOUR_API_KEY"
|
|
94
|
+
[classNames]="{
|
|
95
|
+
trigger: 'btn btn-outline',
|
|
96
|
+
dropdown: 'my-dropdown',
|
|
97
|
+
option: 'my-option',
|
|
98
|
+
optionActive: 'my-option--selected',
|
|
99
|
+
restore: 'my-restore'
|
|
100
|
+
}"
|
|
101
|
+
[labels]="{
|
|
102
|
+
trigger: 'Language',
|
|
103
|
+
dropdownHeader: 'Choose language',
|
|
104
|
+
restore: 'Back to English',
|
|
105
|
+
overlayTitle: 'Translating…',
|
|
106
|
+
overlaySub: 'First time only — instant on next visit.'
|
|
107
|
+
}"
|
|
108
|
+
/>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Angular version compatibility
|
|
114
|
+
|
|
115
|
+
| Version | Support |
|
|
116
|
+
|------------|-----------------------|
|
|
117
|
+
| Angular 15 | ✅ Standalone + NgModule |
|
|
118
|
+
| Angular 16 | ✅ Standalone + NgModule |
|
|
119
|
+
| Angular 17 | ✅ Standalone + NgModule |
|
|
120
|
+
| Angular 18 | ✅ Standalone + NgModule |
|
|
121
|
+
| Angular 19 | ✅ Standalone + NgModule |
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Supported languages
|
|
126
|
+
|
|
127
|
+
Arabic · Chinese · Dutch · French · German · Hebrew · Hindi · Italian · Japanese · Korean · Persian · Polish · Portuguese · Russian · Spanish · Swedish · Turkish · Urdu
|
|
128
|
+
|
|
129
|
+
RTL languages (Arabic, Hebrew, Persian, Urdu) automatically set `dir="rtl"` on the `<html>` element — no extra config needed.
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Links
|
|
134
|
+
|
|
135
|
+
- [Sign up free](https://translify.cc/signup)
|
|
136
|
+
- [Dashboard](https://translify.cc/dashboard)
|
|
137
|
+
- [Full docs](https://translify.cc/docs)
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { Input, ChangeDetectionStrategy, Component, NgModule } from '@angular/core';
|
|
3
|
+
import { NgIf, NgFor, NgClass } from '@angular/common';
|
|
4
|
+
|
|
5
|
+
const SKIP_TAGS = new Set([
|
|
6
|
+
'SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME',
|
|
7
|
+
'CODE', 'PRE', 'SVG', 'MATH', 'INPUT', 'TEXTAREA',
|
|
8
|
+
]);
|
|
9
|
+
function getSavedLang() {
|
|
10
|
+
return localStorage.getItem('translify_lang');
|
|
11
|
+
}
|
|
12
|
+
function saveLang(lang) {
|
|
13
|
+
localStorage.setItem('translify_lang', lang);
|
|
14
|
+
}
|
|
15
|
+
function clearSavedLang() {
|
|
16
|
+
localStorage.removeItem('translify_lang');
|
|
17
|
+
}
|
|
18
|
+
function loadTranslationCache(lang) {
|
|
19
|
+
try {
|
|
20
|
+
const stored = localStorage.getItem(`translify_cache_${lang}`);
|
|
21
|
+
return stored ? new Map(Object.entries(JSON.parse(stored))) : new Map();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return new Map();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function saveTranslationCache(lang, cache) {
|
|
28
|
+
try {
|
|
29
|
+
const obj = {};
|
|
30
|
+
cache.forEach((v, k) => { obj[k] = v; });
|
|
31
|
+
localStorage.setItem(`translify_cache_${lang}`, JSON.stringify(obj));
|
|
32
|
+
}
|
|
33
|
+
catch { }
|
|
34
|
+
}
|
|
35
|
+
function getTextNodes(root) {
|
|
36
|
+
const nodes = [];
|
|
37
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
38
|
+
acceptNode(node) {
|
|
39
|
+
const text = node;
|
|
40
|
+
if (!text.textContent?.trim())
|
|
41
|
+
return NodeFilter.FILTER_REJECT;
|
|
42
|
+
if (SKIP_TAGS.has((text.parentElement?.tagName ?? '')))
|
|
43
|
+
return NodeFilter.FILTER_REJECT;
|
|
44
|
+
if ((text.textContent?.trim().length ?? 0) > 5000)
|
|
45
|
+
return NodeFilter.FILTER_REJECT;
|
|
46
|
+
if (text.parentElement?.closest('[translate="no"]'))
|
|
47
|
+
return NodeFilter.FILTER_REJECT;
|
|
48
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
let n;
|
|
52
|
+
while ((n = walker.nextNode()))
|
|
53
|
+
nodes.push(n);
|
|
54
|
+
return nodes;
|
|
55
|
+
}
|
|
56
|
+
function collectNewTextNodes(node, originalTexts) {
|
|
57
|
+
const nodes = [];
|
|
58
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
59
|
+
const t = node;
|
|
60
|
+
if (!originalTexts.has(t) && t.textContent?.trim())
|
|
61
|
+
nodes.push(t);
|
|
62
|
+
}
|
|
63
|
+
else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
64
|
+
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT);
|
|
65
|
+
let n;
|
|
66
|
+
while ((n = walker.nextNode())) {
|
|
67
|
+
const t = n;
|
|
68
|
+
if (!originalTexts.has(t) &&
|
|
69
|
+
t.textContent?.trim() &&
|
|
70
|
+
!SKIP_TAGS.has(t.parentElement?.tagName ?? '') &&
|
|
71
|
+
!t.parentElement?.closest('[translate="no"]')) {
|
|
72
|
+
nodes.push(t);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return nodes;
|
|
77
|
+
}
|
|
78
|
+
async function fetchLanguages(apiBase) {
|
|
79
|
+
const res = await fetch(`${apiBase}/translate/languages`);
|
|
80
|
+
const json = await res.json();
|
|
81
|
+
return json.data.languages;
|
|
82
|
+
}
|
|
83
|
+
async function callTranslateApi(apiBase, apiKey, domain, texts, targetLang) {
|
|
84
|
+
const results = [];
|
|
85
|
+
for (let i = 0; i < texts.length; i += 100) {
|
|
86
|
+
const chunk = texts.slice(i, i + 100);
|
|
87
|
+
const res = await fetch(`${apiBase}/translate`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
'x-api-key': apiKey,
|
|
92
|
+
'x-domain': domain,
|
|
93
|
+
},
|
|
94
|
+
body: JSON.stringify({ texts: chunk, targetLanguage: targetLang, sourceLanguage: 'auto' }),
|
|
95
|
+
});
|
|
96
|
+
const json = await res.json();
|
|
97
|
+
if (!json.success)
|
|
98
|
+
throw new Error(json.message || 'Translation failed');
|
|
99
|
+
results.push(...json.data.translations);
|
|
100
|
+
}
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class TranslifySelectorComponent {
|
|
105
|
+
ngZone;
|
|
106
|
+
cdr;
|
|
107
|
+
apiKey;
|
|
108
|
+
apiBase = 'https://translify.cc';
|
|
109
|
+
domain = window.location.hostname || 'localhost';
|
|
110
|
+
classNames = {};
|
|
111
|
+
labels = {};
|
|
112
|
+
languages = [];
|
|
113
|
+
activeLang = getSavedLang();
|
|
114
|
+
isOpen = false;
|
|
115
|
+
loadingState = 'idle';
|
|
116
|
+
overlayVisible = false;
|
|
117
|
+
toastVisible = false;
|
|
118
|
+
// All mutable translation state — no change detection needed
|
|
119
|
+
originalTexts = new Map();
|
|
120
|
+
translationCache = new Map();
|
|
121
|
+
currentCacheLang = null;
|
|
122
|
+
isTranslating = false;
|
|
123
|
+
pendingNodes = [];
|
|
124
|
+
observer = null;
|
|
125
|
+
debounceTimer = null;
|
|
126
|
+
overlayTimer = null;
|
|
127
|
+
constructor(ngZone, cdr) {
|
|
128
|
+
this.ngZone = ngZone;
|
|
129
|
+
this.cdr = cdr;
|
|
130
|
+
}
|
|
131
|
+
get activeLangObj() {
|
|
132
|
+
return this.languages.find((l) => l.code === this.activeLang);
|
|
133
|
+
}
|
|
134
|
+
async ngOnInit() {
|
|
135
|
+
if (!this.apiKey) {
|
|
136
|
+
console.error('[Translify] apiKey is required.');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
this.languages = await fetchLanguages(this.apiBase);
|
|
141
|
+
this.cdr.markForCheck();
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
console.error('[Translify] Failed to fetch languages', e);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.ngZone.runOutsideAngular(() => {
|
|
148
|
+
this.startObserver();
|
|
149
|
+
this.patchHistory();
|
|
150
|
+
});
|
|
151
|
+
if (this.activeLang && this.languages.length > 0) {
|
|
152
|
+
await this.translate(this.activeLang);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
ngOnDestroy() {
|
|
156
|
+
this.stopObserver();
|
|
157
|
+
clearTimeout(this.overlayTimer);
|
|
158
|
+
}
|
|
159
|
+
// ── Cache helpers ───────────────────────────────────────────────────────────
|
|
160
|
+
ensureCacheLoaded(lang) {
|
|
161
|
+
if (this.currentCacheLang === lang)
|
|
162
|
+
return;
|
|
163
|
+
this.translationCache = loadTranslationCache(lang);
|
|
164
|
+
this.currentCacheLang = lang;
|
|
165
|
+
}
|
|
166
|
+
applyCache(nodes) {
|
|
167
|
+
const uncached = [];
|
|
168
|
+
nodes.forEach((node) => {
|
|
169
|
+
const src = this.originalTexts.get(node) ?? node.textContent ?? '';
|
|
170
|
+
const hit = this.translationCache.get(src);
|
|
171
|
+
if (hit !== undefined) {
|
|
172
|
+
node.textContent = hit;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
uncached.push(node);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
return uncached;
|
|
179
|
+
}
|
|
180
|
+
// ── Overlay / toast ─────────────────────────────────────────────────────────
|
|
181
|
+
showOverlay() {
|
|
182
|
+
clearTimeout(this.overlayTimer);
|
|
183
|
+
this.ngZone.run(() => {
|
|
184
|
+
this.overlayVisible = false;
|
|
185
|
+
this.loadingState = 'overlay';
|
|
186
|
+
requestAnimationFrame(() => { this.overlayVisible = true; this.cdr.markForCheck(); });
|
|
187
|
+
this.cdr.markForCheck();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
hideOverlay() {
|
|
191
|
+
this.overlayTimer = setTimeout(() => {
|
|
192
|
+
this.ngZone.run(() => { this.overlayVisible = false; this.cdr.markForCheck(); });
|
|
193
|
+
this.overlayTimer = setTimeout(() => {
|
|
194
|
+
this.ngZone.run(() => { this.loadingState = 'idle'; this.cdr.markForCheck(); });
|
|
195
|
+
}, 400);
|
|
196
|
+
}, 600);
|
|
197
|
+
}
|
|
198
|
+
showToast() {
|
|
199
|
+
clearTimeout(this.overlayTimer);
|
|
200
|
+
this.ngZone.run(() => {
|
|
201
|
+
this.loadingState = 'toast';
|
|
202
|
+
requestAnimationFrame(() => { this.toastVisible = true; this.cdr.markForCheck(); });
|
|
203
|
+
this.cdr.markForCheck();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
hideToast() {
|
|
207
|
+
this.ngZone.run(() => {
|
|
208
|
+
this.toastVisible = false;
|
|
209
|
+
this.overlayTimer = setTimeout(() => {
|
|
210
|
+
this.ngZone.run(() => { this.loadingState = 'idle'; this.cdr.markForCheck(); });
|
|
211
|
+
}, 350);
|
|
212
|
+
this.cdr.markForCheck();
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
// ── flushNodes ──────────────────────────────────────────────────────────────
|
|
216
|
+
async flushNodes(nodes, targetLang) {
|
|
217
|
+
if (!nodes.length)
|
|
218
|
+
return;
|
|
219
|
+
if (this.isTranslating) {
|
|
220
|
+
this.pendingNodes.push(...nodes);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
this.isTranslating = true;
|
|
224
|
+
try {
|
|
225
|
+
nodes.forEach((n) => {
|
|
226
|
+
if (!this.originalTexts.has(n))
|
|
227
|
+
this.originalTexts.set(n, n.textContent ?? '');
|
|
228
|
+
});
|
|
229
|
+
const uncached = this.applyCache(nodes);
|
|
230
|
+
if (uncached.length > 0) {
|
|
231
|
+
const texts = uncached.map((n) => this.originalTexts.get(n));
|
|
232
|
+
const translations = await callTranslateApi(this.apiBase, this.apiKey, this.domain, texts, targetLang);
|
|
233
|
+
uncached.forEach((node, i) => {
|
|
234
|
+
if (translations[i] !== undefined) {
|
|
235
|
+
this.translationCache.set(this.originalTexts.get(node), translations[i]);
|
|
236
|
+
node.textContent = translations[i];
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
saveTranslationCache(targetLang, this.translationCache);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
console.error('[Translify] Dynamic translation error', e);
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
this.isTranslating = false;
|
|
247
|
+
if (this.pendingNodes.length > 0) {
|
|
248
|
+
const next = this.pendingNodes.splice(0);
|
|
249
|
+
this.flushNodes(next, targetLang);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ── MutationObserver ────────────────────────────────────────────────────────
|
|
254
|
+
startObserver() {
|
|
255
|
+
if (this.observer)
|
|
256
|
+
return;
|
|
257
|
+
this.observer = new MutationObserver((mutations) => {
|
|
258
|
+
if (!this.activeLang)
|
|
259
|
+
return;
|
|
260
|
+
const targetLang = this.activeLang;
|
|
261
|
+
const newNodes = [];
|
|
262
|
+
mutations.forEach((mutation) => {
|
|
263
|
+
if (mutation.type !== 'childList')
|
|
264
|
+
return;
|
|
265
|
+
mutation.addedNodes.forEach((node) => {
|
|
266
|
+
newNodes.push(...collectNewTextNodes(node, this.originalTexts));
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
if (!newNodes.length)
|
|
270
|
+
return;
|
|
271
|
+
const uncached = [];
|
|
272
|
+
newNodes.forEach((node) => {
|
|
273
|
+
const src = node.textContent ?? '';
|
|
274
|
+
const hit = this.translationCache.get(src);
|
|
275
|
+
if (hit !== undefined) {
|
|
276
|
+
this.originalTexts.set(node, src);
|
|
277
|
+
node.textContent = hit;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
uncached.push(node);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
if (!uncached.length)
|
|
284
|
+
return;
|
|
285
|
+
this.pendingNodes.push(...uncached);
|
|
286
|
+
if (!this.isTranslating) {
|
|
287
|
+
clearTimeout(this.debounceTimer);
|
|
288
|
+
this.debounceTimer = setTimeout(() => {
|
|
289
|
+
const toFlush = this.pendingNodes.splice(0);
|
|
290
|
+
this.flushNodes(toFlush, targetLang);
|
|
291
|
+
}, 50);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
this.observer.observe(document.body, { childList: true, subtree: true });
|
|
295
|
+
}
|
|
296
|
+
stopObserver() {
|
|
297
|
+
if (this.observer) {
|
|
298
|
+
this.observer.disconnect();
|
|
299
|
+
this.observer = null;
|
|
300
|
+
}
|
|
301
|
+
clearTimeout(this.debounceTimer);
|
|
302
|
+
}
|
|
303
|
+
// ── SPA route change detection ──────────────────────────────────────────────
|
|
304
|
+
patchHistory() {
|
|
305
|
+
const orig = history.pushState.bind(history);
|
|
306
|
+
const self = this;
|
|
307
|
+
history.pushState = function (state, unused, url) {
|
|
308
|
+
orig(state, unused, url);
|
|
309
|
+
self.onRouteChange();
|
|
310
|
+
};
|
|
311
|
+
window.addEventListener('popstate', () => this.onRouteChange());
|
|
312
|
+
}
|
|
313
|
+
onRouteChange() {
|
|
314
|
+
if (!this.activeLang)
|
|
315
|
+
return;
|
|
316
|
+
const targetLang = this.activeLang;
|
|
317
|
+
setTimeout(() => {
|
|
318
|
+
const nodes = getTextNodes(document.body).filter((n) => !this.originalTexts.has(n));
|
|
319
|
+
if (!nodes.length)
|
|
320
|
+
return;
|
|
321
|
+
this.pendingNodes.push(...nodes);
|
|
322
|
+
if (!this.isTranslating) {
|
|
323
|
+
const toFlush = this.pendingNodes.splice(0);
|
|
324
|
+
this.flushNodes(toFlush, targetLang);
|
|
325
|
+
}
|
|
326
|
+
}, 100);
|
|
327
|
+
}
|
|
328
|
+
// ── Main translate ──────────────────────────────────────────────────────────
|
|
329
|
+
async translate(targetLang) {
|
|
330
|
+
if (this.isTranslating)
|
|
331
|
+
return;
|
|
332
|
+
this.isTranslating = true;
|
|
333
|
+
try {
|
|
334
|
+
this.ensureCacheLoaded(targetLang);
|
|
335
|
+
const isFirstEver = this.translationCache.size === 0;
|
|
336
|
+
if (this.originalTexts.size > 0) {
|
|
337
|
+
this.originalTexts.forEach((text, node) => { node.textContent = text; });
|
|
338
|
+
}
|
|
339
|
+
const nodes = getTextNodes(document.body);
|
|
340
|
+
if (!nodes.length)
|
|
341
|
+
return;
|
|
342
|
+
nodes.forEach((node) => {
|
|
343
|
+
if (!this.originalTexts.has(node))
|
|
344
|
+
this.originalTexts.set(node, node.textContent ?? '');
|
|
345
|
+
});
|
|
346
|
+
const uncached = this.applyCache(nodes);
|
|
347
|
+
const lang = this.languages.find((l) => l.code === targetLang);
|
|
348
|
+
document.documentElement.setAttribute('dir', lang?.isRTL ? 'rtl' : 'ltr');
|
|
349
|
+
saveLang(targetLang);
|
|
350
|
+
this.ngZone.run(() => { this.activeLang = targetLang; this.cdr.markForCheck(); });
|
|
351
|
+
if (uncached.length > 0) {
|
|
352
|
+
if (isFirstEver)
|
|
353
|
+
this.showOverlay();
|
|
354
|
+
else
|
|
355
|
+
this.showToast();
|
|
356
|
+
const texts = uncached.map((n) => this.originalTexts.get(n));
|
|
357
|
+
const translations = await callTranslateApi(this.apiBase, this.apiKey, this.domain, texts, targetLang);
|
|
358
|
+
uncached.forEach((node, i) => {
|
|
359
|
+
if (translations[i] !== undefined) {
|
|
360
|
+
this.translationCache.set(this.originalTexts.get(node), translations[i]);
|
|
361
|
+
node.textContent = translations[i];
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
saveTranslationCache(targetLang, this.translationCache);
|
|
365
|
+
if (isFirstEver)
|
|
366
|
+
this.hideOverlay();
|
|
367
|
+
else
|
|
368
|
+
this.hideToast();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
console.error('[Translify] Translation error', e);
|
|
373
|
+
this.hideOverlay();
|
|
374
|
+
this.hideToast();
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
this.isTranslating = false;
|
|
378
|
+
if (this.pendingNodes.length > 0) {
|
|
379
|
+
const next = this.pendingNodes.splice(0);
|
|
380
|
+
this.flushNodes(next, this.activeLang);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// ── Restore ─────────────────────────────────────────────────────────────────
|
|
385
|
+
restore() {
|
|
386
|
+
this.stopObserver();
|
|
387
|
+
this.originalTexts.forEach((text, node) => { node.textContent = text; });
|
|
388
|
+
this.originalTexts.clear();
|
|
389
|
+
document.documentElement.setAttribute('dir', 'ltr');
|
|
390
|
+
clearSavedLang();
|
|
391
|
+
this.activeLang = null;
|
|
392
|
+
this.isOpen = false;
|
|
393
|
+
this.cdr.markForCheck();
|
|
394
|
+
this.ngZone.runOutsideAngular(() => this.startObserver());
|
|
395
|
+
}
|
|
396
|
+
// ── UI handlers ─────────────────────────────────────────────────────────────
|
|
397
|
+
handleSelect(langCode) {
|
|
398
|
+
this.isOpen = false;
|
|
399
|
+
this.translate(langCode);
|
|
400
|
+
}
|
|
401
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: TranslifySelectorComponent, deps: [{ token: i0.NgZone }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
|
|
402
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.5", type: TranslifySelectorComponent, isStandalone: true, selector: "translify-selector", inputs: { apiKey: "apiKey", apiBase: "apiBase", domain: "domain", classNames: "classNames", labels: "labels" }, ngImport: i0, template: `
|
|
403
|
+
<div class="translify-selector" (clickOutside)="isOpen = false">
|
|
404
|
+
<button
|
|
405
|
+
class="translify-trigger"
|
|
406
|
+
[class]="classNames.trigger || ''"
|
|
407
|
+
(click)="isOpen = !isOpen"
|
|
408
|
+
[disabled]="loadingState !== 'idle' || languages.length === 0"
|
|
409
|
+
[attr.aria-expanded]="isOpen"
|
|
410
|
+
>
|
|
411
|
+
<span>🌐</span>
|
|
412
|
+
<span>{{ activeLangObj ? activeLangObj.nativeName : (labels.trigger || 'Translate') }}</span>
|
|
413
|
+
<span class="translify-trigger-chevron" [class.open]="isOpen">▾</span>
|
|
414
|
+
</button>
|
|
415
|
+
|
|
416
|
+
<div *ngIf="isOpen" class="translify-dropdown" [class]="classNames.dropdown || ''">
|
|
417
|
+
<div class="translify-dropdown-header">{{ labels.dropdownHeader || 'Select Language' }}</div>
|
|
418
|
+
|
|
419
|
+
<button
|
|
420
|
+
*ngFor="let lang of languages"
|
|
421
|
+
class="translify-option"
|
|
422
|
+
[class.active]="activeLang === lang.code"
|
|
423
|
+
(click)="handleSelect(lang.code)"
|
|
424
|
+
>
|
|
425
|
+
<span>{{ lang.nativeName }}</span>
|
|
426
|
+
<span class="translify-option-name">{{ lang.name }}</span>
|
|
427
|
+
<span *ngIf="lang.isRTL" class="translify-option-rtl">RTL</span>
|
|
428
|
+
</button>
|
|
429
|
+
|
|
430
|
+
<button *ngIf="activeLang" class="translify-restore" (click)="restore()">
|
|
431
|
+
↩ {{ labels.restore || 'Show original' }}
|
|
432
|
+
</button>
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<div
|
|
437
|
+
*ngIf="loadingState === 'overlay'"
|
|
438
|
+
class="translify-overlay"
|
|
439
|
+
[style.opacity]="overlayVisible ? '1' : '0'"
|
|
440
|
+
>
|
|
441
|
+
<div class="translify-overlay-spinner"></div>
|
|
442
|
+
<div>
|
|
443
|
+
<div class="translify-overlay-title">{{ labels.overlayTitle || 'Translating page…' }}</div>
|
|
444
|
+
<div class="translify-overlay-sub">{{ labels.overlaySub || 'First time only — instant on next visit.' }}</div>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<div
|
|
449
|
+
*ngIf="loadingState === 'toast'"
|
|
450
|
+
class="translify-toast"
|
|
451
|
+
[style.opacity]="toastVisible ? '1' : '0'"
|
|
452
|
+
[style.transform]="toastVisible ? 'translateY(0)' : 'translateY(8px)'"
|
|
453
|
+
>
|
|
454
|
+
⏳ Translating…
|
|
455
|
+
</div>
|
|
456
|
+
`, isInline: true, styles: [".translify-selector{position:relative;display:inline-block}.translify-trigger{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;font-size:14px;color:#374151;transition:border-color .15s}.translify-trigger:hover{border-color:#9ca3af}.translify-trigger:disabled{opacity:.5;cursor:not-allowed}.translify-trigger-chevron{font-size:10px;transition:transform .2s}.translify-trigger-chevron.open{transform:rotate(180deg)}.translify-dropdown{position:absolute;top:calc(100% + 6px);left:0;z-index:9999;background:#fff;border:1px solid #e5e7eb;border-radius:12px;min-width:220px;max-height:320px;overflow-y:auto;box-shadow:0 8px 24px #0000001f;display:flex;flex-direction:column}.translify-dropdown-header{padding:10px 14px;font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid #f3f4f6;flex-shrink:0}.translify-option{display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;font-size:14px;color:#111827;border:none;background:none;width:100%;text-align:left;transition:background .1s}.translify-option:hover{background:#f9fafb}.translify-option.active{background:#eff6ff;color:#2563eb;font-weight:600}.translify-option-name{font-size:12px;color:#9ca3af;margin-left:auto}.translify-option-rtl{font-size:10px;color:#9ca3af;background:#f3f4f6;padding:2px 5px;border-radius:4px}.translify-restore{display:flex;align-items:center;gap:6px;padding:10px 14px;cursor:pointer;font-size:13px;color:#ef4444;border:none;background:none;width:100%;text-align:left;border-top:1px solid #f3f4f6;flex-shrink:0;transition:background .1s}.translify-restore:hover{background:#fff5f5}.translify-toast{position:fixed;bottom:84px;right:24px;z-index:2147483647;background:#1e293b;color:#fff;padding:10px 16px;border-radius:10px;font-size:13px;pointer-events:none;transition:opacity .3s ease,transform .3s ease}.translify-overlay{position:fixed;inset:0;z-index:2147483646;background:#ffffffd9;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;transition:opacity .35s ease}.translify-overlay-spinner{width:40px;height:40px;border-radius:50%;border:3px solid #e5e7eb;border-top-color:#2563eb;animation:tlfy-spin .7s linear infinite}@keyframes tlfy-spin{to{transform:rotate(360deg)}}.translify-overlay-title{font-size:15px;font-weight:600;color:#111827;text-align:center}.translify-overlay-sub{font-size:12px;color:#6b7280;margin-top:4px;text-align:center}\n"], dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
457
|
+
}
|
|
458
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: TranslifySelectorComponent, decorators: [{
|
|
459
|
+
type: Component,
|
|
460
|
+
args: [{ selector: 'translify-selector', standalone: true, imports: [NgIf, NgFor, NgClass], changeDetection: ChangeDetectionStrategy.OnPush, template: `
|
|
461
|
+
<div class="translify-selector" (clickOutside)="isOpen = false">
|
|
462
|
+
<button
|
|
463
|
+
class="translify-trigger"
|
|
464
|
+
[class]="classNames.trigger || ''"
|
|
465
|
+
(click)="isOpen = !isOpen"
|
|
466
|
+
[disabled]="loadingState !== 'idle' || languages.length === 0"
|
|
467
|
+
[attr.aria-expanded]="isOpen"
|
|
468
|
+
>
|
|
469
|
+
<span>🌐</span>
|
|
470
|
+
<span>{{ activeLangObj ? activeLangObj.nativeName : (labels.trigger || 'Translate') }}</span>
|
|
471
|
+
<span class="translify-trigger-chevron" [class.open]="isOpen">▾</span>
|
|
472
|
+
</button>
|
|
473
|
+
|
|
474
|
+
<div *ngIf="isOpen" class="translify-dropdown" [class]="classNames.dropdown || ''">
|
|
475
|
+
<div class="translify-dropdown-header">{{ labels.dropdownHeader || 'Select Language' }}</div>
|
|
476
|
+
|
|
477
|
+
<button
|
|
478
|
+
*ngFor="let lang of languages"
|
|
479
|
+
class="translify-option"
|
|
480
|
+
[class.active]="activeLang === lang.code"
|
|
481
|
+
(click)="handleSelect(lang.code)"
|
|
482
|
+
>
|
|
483
|
+
<span>{{ lang.nativeName }}</span>
|
|
484
|
+
<span class="translify-option-name">{{ lang.name }}</span>
|
|
485
|
+
<span *ngIf="lang.isRTL" class="translify-option-rtl">RTL</span>
|
|
486
|
+
</button>
|
|
487
|
+
|
|
488
|
+
<button *ngIf="activeLang" class="translify-restore" (click)="restore()">
|
|
489
|
+
↩ {{ labels.restore || 'Show original' }}
|
|
490
|
+
</button>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<div
|
|
495
|
+
*ngIf="loadingState === 'overlay'"
|
|
496
|
+
class="translify-overlay"
|
|
497
|
+
[style.opacity]="overlayVisible ? '1' : '0'"
|
|
498
|
+
>
|
|
499
|
+
<div class="translify-overlay-spinner"></div>
|
|
500
|
+
<div>
|
|
501
|
+
<div class="translify-overlay-title">{{ labels.overlayTitle || 'Translating page…' }}</div>
|
|
502
|
+
<div class="translify-overlay-sub">{{ labels.overlaySub || 'First time only — instant on next visit.' }}</div>
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
|
|
506
|
+
<div
|
|
507
|
+
*ngIf="loadingState === 'toast'"
|
|
508
|
+
class="translify-toast"
|
|
509
|
+
[style.opacity]="toastVisible ? '1' : '0'"
|
|
510
|
+
[style.transform]="toastVisible ? 'translateY(0)' : 'translateY(8px)'"
|
|
511
|
+
>
|
|
512
|
+
⏳ Translating…
|
|
513
|
+
</div>
|
|
514
|
+
`, styles: [".translify-selector{position:relative;display:inline-block}.translify-trigger{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;cursor:pointer;font-size:14px;color:#374151;transition:border-color .15s}.translify-trigger:hover{border-color:#9ca3af}.translify-trigger:disabled{opacity:.5;cursor:not-allowed}.translify-trigger-chevron{font-size:10px;transition:transform .2s}.translify-trigger-chevron.open{transform:rotate(180deg)}.translify-dropdown{position:absolute;top:calc(100% + 6px);left:0;z-index:9999;background:#fff;border:1px solid #e5e7eb;border-radius:12px;min-width:220px;max-height:320px;overflow-y:auto;box-shadow:0 8px 24px #0000001f;display:flex;flex-direction:column}.translify-dropdown-header{padding:10px 14px;font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.06em;border-bottom:1px solid #f3f4f6;flex-shrink:0}.translify-option{display:flex;align-items:center;gap:8px;padding:10px 14px;cursor:pointer;font-size:14px;color:#111827;border:none;background:none;width:100%;text-align:left;transition:background .1s}.translify-option:hover{background:#f9fafb}.translify-option.active{background:#eff6ff;color:#2563eb;font-weight:600}.translify-option-name{font-size:12px;color:#9ca3af;margin-left:auto}.translify-option-rtl{font-size:10px;color:#9ca3af;background:#f3f4f6;padding:2px 5px;border-radius:4px}.translify-restore{display:flex;align-items:center;gap:6px;padding:10px 14px;cursor:pointer;font-size:13px;color:#ef4444;border:none;background:none;width:100%;text-align:left;border-top:1px solid #f3f4f6;flex-shrink:0;transition:background .1s}.translify-restore:hover{background:#fff5f5}.translify-toast{position:fixed;bottom:84px;right:24px;z-index:2147483647;background:#1e293b;color:#fff;padding:10px 16px;border-radius:10px;font-size:13px;pointer-events:none;transition:opacity .3s ease,transform .3s ease}.translify-overlay{position:fixed;inset:0;z-index:2147483646;background:#ffffffd9;-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;transition:opacity .35s ease}.translify-overlay-spinner{width:40px;height:40px;border-radius:50%;border:3px solid #e5e7eb;border-top-color:#2563eb;animation:tlfy-spin .7s linear infinite}@keyframes tlfy-spin{to{transform:rotate(360deg)}}.translify-overlay-title{font-size:15px;font-weight:600;color:#111827;text-align:center}.translify-overlay-sub{font-size:12px;color:#6b7280;margin-top:4px;text-align:center}\n"] }]
|
|
515
|
+
}], ctorParameters: () => [{ type: i0.NgZone }, { type: i0.ChangeDetectorRef }], propDecorators: { apiKey: [{
|
|
516
|
+
type: Input
|
|
517
|
+
}], apiBase: [{
|
|
518
|
+
type: Input
|
|
519
|
+
}], domain: [{
|
|
520
|
+
type: Input
|
|
521
|
+
}], classNames: [{
|
|
522
|
+
type: Input
|
|
523
|
+
}], labels: [{
|
|
524
|
+
type: Input
|
|
525
|
+
}] } });
|
|
526
|
+
|
|
527
|
+
// For NgModule-based apps (Angular 15+)
|
|
528
|
+
// Standalone component apps can import TranslifySelectorComponent directly
|
|
529
|
+
class TranslifyModule {
|
|
530
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: TranslifyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
|
|
531
|
+
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "21.2.5", ngImport: i0, type: TranslifyModule, imports: [TranslifySelectorComponent], exports: [TranslifySelectorComponent] });
|
|
532
|
+
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: TranslifyModule });
|
|
533
|
+
}
|
|
534
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: TranslifyModule, decorators: [{
|
|
535
|
+
type: NgModule,
|
|
536
|
+
args: [{
|
|
537
|
+
imports: [TranslifySelectorComponent],
|
|
538
|
+
exports: [TranslifySelectorComponent],
|
|
539
|
+
}]
|
|
540
|
+
}] });
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Generated bundle index. Do not edit.
|
|
544
|
+
*/
|
|
545
|
+
|
|
546
|
+
export { TranslifyModule, TranslifySelectorComponent };
|
|
547
|
+
//# sourceMappingURL=translifycc-angular.mjs.map
|