Skip to content

Commit 908aad3

Browse files
authored
Merge pull request #5 from apicore-engineering/develop
Implement badge cache, move encode to plugin
2 parents e1f9f94 + 0448984 commit 908aad3

File tree

6 files changed

+213
-106
lines changed

6 files changed

+213
-106
lines changed

README.md

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
# Live Translator Plugin for Vue i18n
2-
> [!WARNING]
3-
> Plugin has been updated to Vue 3 only. To use with Vue 2 please use the legacy `vue2` branch.
4-
52
> [!WARNING]
63
> This plugin makes significant changes to the DOM, possibly messing up your layout and appearance. We advise you **NOT TO USE IT IN PRODUCTION**, only in development and staging instances.
74
@@ -14,32 +11,27 @@ npm i -s https://github.com/apicore-engineering/vue-i18n-live-translator-plugin
1411
```
1512

1613
## Use
17-
Encode locale messages before passing them to `createI18n`:
18-
```typescript
19-
// i18n.ts
20-
import { createI18n } from 'vue-i18n'
21-
import { encodeMessages } from 'vue-i18n-live-translator-plugin'
22-
23-
export const i18n = createI18n({
24-
// ...
25-
messages: encodeMessages(messages),
26-
})
27-
```
28-
Use plugin to decode info from locale messages:
2914
```typescript
3015
// main.ts
16+
import { i18n } from './i18n'
3117
import { LiveTranslatorPlugin, TranslationMeta } from 'vue-i18n-live-translator-plugin'
3218

33-
// const app = createApp(App)
34-
// app.use(i18n)
35-
// ...
19+
const app = createApp(App)
20+
app.use(i18n)
3621

3722
app.use(LiveTranslatorPlugin, {
23+
i18n, // i18n instance
3824
translationLink (meta: TranslationMeta) {
39-
return '' // your platform-specific link to the translation software
25+
// your platform-specific link to the translation software
26+
return ''
4027
},
4128
persist: true,
29+
root: document.getElementById('app'), // root of your vue app, this is where the plugin looks for translated strings. defaults to document.documentElement
30+
refreshRate: 100, // max refresh rate (ms)
31+
checkVisibility: true, // hide elements that are covered
4232
})
33+
34+
app.mount('#app')
4335
```
4436

4537
## Weblate example

dist/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ export type TranslationMeta = {
66
path: string;
77
};
88
type LiveTranslatorPluginOptions = {
9+
i18n: any;
910
translationLink: (meta: TranslationMeta) => string;
1011
persist?: boolean;
1112
root?: HTMLElement;
1213
refreshRate?: number;
1314
checkVisibility?: boolean;
1415
};
15-
export declare function encodeMessages(messagesObject: any): any;
1616
export declare const LiveTranslatorPlugin: {
1717
install(app: any, options: LiveTranslatorPluginOptions): void;
1818
};

dist/index.js

Lines changed: 91 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,26 @@ import set from 'lodash/set';
55
const css = `
66
.live-translator-enable-button {
77
position: fixed !important;
8-
top: 0;
9-
left: 0;
8+
top: 2px;
9+
left: 2px;
1010
z-index: 10000;
1111
padding: 2px;
1212
color: black;
1313
background: rgba(255, 255, 255, 0.6);
1414
font-family: sans-serif;
1515
font-size: 8px;
16+
border-radius: 10px;
17+
display: flex;
18+
gap: 2px;
19+
align-items: center;
1620
}
1721
.live-translator-enable-button:hover {
1822
background: white;
1923
}
2024
.live-translator-enable-button-indicator {
2125
display: inline-block;
22-
height: 6px;
23-
width: 6px;
24-
margin-left: 2px;
26+
height: 10px;
27+
width: 10px;
2528
border-radius: 100%;
2629
background-color: red;
2730
}
@@ -82,22 +85,20 @@ function deepForIn(object, fn) {
8285
};
8386
forIn(object, iteratee);
8487
}
85-
export function encodeMessages(messagesObject) {
88+
function encodeMessages(messagesObject, locale) {
8689
const messages = cloneDeep(messagesObject);
87-
forIn(messages, (localeMessages, locale) => {
88-
deepForIn(localeMessages, (message, path) => {
89-
const parts = message.split('|').map(part => part.trim());
90-
for (let i = 0; i < parts.length; i++) {
91-
const meta = ZeroWidthEncoder.encode(JSON.stringify({
92-
locale,
93-
message,
94-
path,
95-
choice: i || undefined,
96-
}));
97-
parts[i] = meta + parts[i];
98-
}
99-
set(localeMessages, path, parts.join(' | '));
100-
});
90+
deepForIn(messages, (message, path) => {
91+
const parts = message.split('|').map(part => part.trim());
92+
for (let i = 0; i < parts.length; i++) {
93+
const meta = ZeroWidthEncoder.encode(JSON.stringify({
94+
locale,
95+
message,
96+
path,
97+
choice: i || undefined,
98+
}));
99+
parts[i] = meta + parts[i];
100+
}
101+
set(messages, path, parts.join(' | '));
101102
});
102103
return messages;
103104
}
@@ -149,13 +150,40 @@ class ZeroWidthEncoder {
149150
return text;
150151
}
151152
}
153+
class Cache {
154+
_cache = {};
155+
has(key) {
156+
return key in this._cache;
157+
}
158+
store(key, value) {
159+
this._cache[key] = { value, locked: true };
160+
}
161+
lock(key) {
162+
this._cache[key].locked = true;
163+
}
164+
clear(force = false) {
165+
for (const key in this._cache) {
166+
if (!force && this._cache[key].locked) {
167+
this._cache[key].locked = false;
168+
}
169+
else {
170+
this._cache[key].value.remove();
171+
delete this._cache[key];
172+
}
173+
}
174+
}
175+
get length() {
176+
return Object.keys(this._cache).length;
177+
}
178+
}
152179
class LiveTranslatorManager {
153180
_enabled;
154181
_options;
155182
_enableButton;
156183
_indicator;
157184
_box;
158185
_wrapper;
186+
_cache = new Cache();
159187
constructor(options) {
160188
this._enabled = false;
161189
this._options = options;
@@ -190,7 +218,11 @@ class LiveTranslatorManager {
190218
this._box.classList.add('live-translator-box');
191219
this._wrapper.appendChild(this._box);
192220
// initialize encode
193-
// encode is moved to i18n.ts file
221+
for (const locale of this.i18n.availableLocales) {
222+
let messages = this.i18n.getLocaleMessage(locale);
223+
messages = encodeMessages(messages, locale);
224+
this.i18n.setLocaleMessage(locale, messages);
225+
}
194226
// initialize decode & render
195227
const throttler = throttle(() => this.render(), this._options.refreshRate || 50);
196228
const observer = new MutationObserver(throttler);
@@ -208,6 +240,9 @@ class LiveTranslatorManager {
208240
get root() {
209241
return this._options.root || document.documentElement;
210242
}
243+
get i18n() {
244+
return this._options.i18n.global || this._options.i18n;
245+
}
211246
toggle(enable) {
212247
if (enable !== undefined) {
213248
this._enabled = enable;
@@ -219,14 +254,11 @@ class LiveTranslatorManager {
219254
localStorage.setItem('live-translator-enabled', JSON.stringify(this._enabled));
220255
}
221256
console.log(`%c Live Translator ${this._enabled ? 'ON' : 'OFF'} `, 'background: #222; color: #bada55');
257+
if (!this._enabled) {
258+
this._cache.clear(true);
259+
}
222260
}
223261
render() {
224-
this._box.style.display = 'none';
225-
document.
226-
querySelectorAll('.live-translator-badge-container').
227-
forEach((elem) => {
228-
elem.remove();
229-
});
230262
this._indicator.style.background = this._enabled ? 'lightgreen' : 'red';
231263
if (!this._enabled) {
232264
return;
@@ -236,13 +268,16 @@ class LiveTranslatorManager {
236268
while (queue.length > 0) {
237269
const node = queue.pop();
238270
const badges = [];
271+
let cacheKeyParts = [];
239272
if (node instanceof Text) {
240273
const matches = node.textContent.match(re);
241274
for (const match of matches ?? []) {
242275
const meta = JSON.parse(ZeroWidthEncoder.decode(match));
243276
const badge = createBadge(meta, this._options, node);
244277
badge.addEventListener('mouseenter', () => this.showBox(node));
278+
badge.addEventListener('mouseleave', () => this.hideBox());
245279
badges.push(badge);
280+
cacheKeyParts.push(meta.path);
246281
}
247282
}
248283
const attributes = (node.attributes ? [...node.attributes] : [])
@@ -253,7 +288,9 @@ class LiveTranslatorManager {
253288
const meta = JSON.parse(ZeroWidthEncoder.decode(m));
254289
const badge = createBadge(meta, this._options, node, attribute.name);
255290
badge.addEventListener('mouseenter', () => this.showBox(node, true));
291+
badge.addEventListener('mouseleave', () => this.hideBox());
256292
badges.push(badge);
293+
cacheKeyParts.push(meta.path);
257294
}
258295
}
259296
if (badges.length) {
@@ -264,13 +301,19 @@ class LiveTranslatorManager {
264301
const clientRect = getBoundingClientRect(node);
265302
position.top = clientRect.top + window.scrollY;
266303
position.left = clientRect.left + window.screenX;
267-
isVisible = isVisible || node.parentElement.contains(document.elementFromPoint(clientRect.left + clientRect.width / 2, clientRect.top + clientRect.height / 2));
304+
const elemOnTop = document.elementFromPoint(clientRect.left + clientRect.width / 2, clientRect.top + clientRect.height / 2);
305+
isVisible = isVisible ||
306+
node.parentElement.contains(elemOnTop) ||
307+
this._wrapper.contains(elemOnTop);
268308
}
269309
else {
270310
const clientRect = node.getClientRects()[0];
271311
position.top = clientRect.top + clientRect.height - 10 + window.scrollY;
272312
position.left = clientRect.left + window.screenX;
273-
isVisible = isVisible || node.contains(document.elementFromPoint(clientRect.left + clientRect.width / 2, clientRect.top + clientRect.height / 2));
313+
const elemOnTop = document.elementFromPoint(clientRect.left + clientRect.width / 2, clientRect.top + clientRect.height / 2);
314+
isVisible = isVisible ||
315+
node.contains(elemOnTop) ||
316+
this._wrapper.contains(elemOnTop);
274317
}
275318
if (!isVisible) {
276319
continue;
@@ -280,19 +323,28 @@ class LiveTranslatorManager {
280323
// console.warn('Could not get bounding box for', node);
281324
continue;
282325
}
283-
const container = document.createElement('span');
284-
container.classList.add('live-translator-badge-container');
285-
container.style.top = position.top + 'px';
286-
container.style.left = position.left + 'px';
287-
this._wrapper.appendChild(container);
288-
for (const badge of badges) {
289-
container.appendChild(badge);
326+
cacheKeyParts.unshift(position.left, position.top);
327+
const cacheKey = cacheKeyParts.join(';');
328+
if (!this._cache.has(cacheKey)) {
329+
const container = document.createElement('span');
330+
container.classList.add('live-translator-badge-container');
331+
container.style.top = position.top + 'px';
332+
container.style.left = position.left + 'px';
333+
this._wrapper.appendChild(container);
334+
for (const badge of badges) {
335+
container.appendChild(badge);
336+
}
337+
this._cache.store(cacheKey, container);
338+
}
339+
else {
340+
this._cache.lock(cacheKey);
290341
}
291342
}
292343
for (const child of node.childNodes) {
293344
queue.push(child);
294345
}
295346
}
347+
this._cache.clear();
296348
}
297349
showBox(node, attribute = false) {
298350
const rect = !attribute ? getBoundingClientRect(node) : node.getClientRects()[0];
@@ -312,6 +364,9 @@ class LiveTranslatorManager {
312364
this._box.style.height = rect.height + 2 * padding + 'px';
313365
this._box.style.display = 'block';
314366
}
367+
hideBox() {
368+
this._box.style.display = 'none';
369+
}
315370
}
316371
const createBadge = (meta, options, node, attribute) => {
317372
const badge = document.createElement('a');

src/demo/i18n.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import { createI18n } from "vue-i18n"
22
import en from './lang/en.json'
33
import hu from './lang/hu.json'
4-
import { encodeMessages } from ".."
54

65
export const i18n = createI18n({
76
legacy: false,
87
locale: 'en',
98
fallbackLocale: 'en',
10-
messages: encodeMessages({
11-
en,
12-
hu,
13-
}),
9+
messages: { en, hu },
1410
})

src/demo/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ const app = createApp(App)
88
app.use(i18n)
99

1010
app.use(LiveTranslatorPlugin, {
11+
i18n,
1112
translationLink(meta: TranslationMeta) {
1213
return `?meta=${encodeURIComponent(JSON.stringify(meta))}`
1314
},
1415
persist: true,
1516
root: document.getElementById('app'),
16-
refreshRate: 50,
17+
refreshRate: 100,
1718
checkVisibility: true,
1819
})
1920

0 commit comments

Comments
 (0)