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