Skip to content

Commit 9dec65f

Browse files
author
Sebi Nemeth
committed
Merge branch 'main' into develop
2 parents 834f039 + bb23d6e commit 9dec65f

File tree

18 files changed

+1794
-122
lines changed

18 files changed

+1794
-122
lines changed

.github/workflows/pages.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: GH Pages Demo app
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout
14+
uses: actions/checkout@v4
15+
- name: Setup Pages
16+
uses: actions/configure-pages@v3
17+
- name: Setup Node.js
18+
uses: actions/setup-node@v3
19+
with:
20+
node-version: '20.x'
21+
- run: npm ci
22+
- run: npm run build:demo
23+
- name: Upload Artifact
24+
uses: actions/upload-pages-artifact@v2
25+
with:
26+
path: './dist-demo'
27+
28+
deploy:
29+
environment:
30+
name: github-pages
31+
url: ${{steps.deployment.outputs.page_url}}
32+
permissions:
33+
id-token: write
34+
pages: write
35+
runs-on: ubuntu-latest
36+
needs: build
37+
steps:
38+
- name: Deploy to GitHub Pages
39+
id: deployment
40+
uses: actions/deploy-pages@v2

.vscode/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"i18n-ally.localesPaths": [
3+
"src/demo/lang"
4+
]
5+
}

README.md

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,40 @@
1-
# Live Translator Plugin for Vue 2
1+
# 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+
5+
> [!WARNING]
6+
> 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.
7+
8+
## Demo
9+
Find a live demo app at: [https://apicore-engineering.github.io/vue-i18n-live-translator-plugin/](https://apicore-engineering.github.io/vue-i18n-live-translator-plugin/)
210

311
## Install
412
```bash
513
npm i -s https://github.com/apicore-engineering/vue-i18n-live-translator-plugin
614
```
715

816
## Use
17+
Encode locale messages before passing them to `createI18n`:
918
```typescript
10-
import LiveTranslatorPlugin, { TranslationMeta } from 'vue-i18n-live-translator-plugin'
19+
// i18n.ts
20+
import { createI18n } from 'vue-i18n'
21+
import { encodeMessages } from 'vue-i18n-live-translator-plugin'
1122

12-
Vue.use(LiveTranslatorPlugin, {
13-
i18n,
23+
export const i18n = createI18n({
24+
// ...
25+
messages: encodeMessages(messages),
26+
})
27+
```
28+
Use plugin to decode info from locale messages:
29+
```typescript
30+
// main.ts
31+
import { LiveTranslatorPlugin, TranslationMeta } from 'vue-i18n-live-translator-plugin'
32+
33+
// const app = createApp(App)
34+
// app.use(i18n)
35+
// ...
36+
37+
app.use(LiveTranslatorPlugin, {
1438
translationLink (meta: TranslationMeta) {
1539
return '' // your platform-specific link to the translation software
1640
},
@@ -37,4 +61,7 @@ npm install
3761
```
3862
```bash
3963
husky install
64+
```
65+
```bash
66+
npm run dev # demo & dev app with vite
4067
```

dist/index.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import VueI18n from 'vue-i18n';
21
export type TranslationMeta = {
32
locale: string;
43
message: string;
54
values?: object;
5+
choice?: number;
66
path: string;
77
};
88
type LiveTranslatorPluginOptions = {
9-
i18n: VueI18n;
109
translationLink: (meta: TranslationMeta) => string;
1110
persist?: boolean;
1211
};
12+
export declare function encodeMessages(messagesObject: any): any;
1313
export declare const LiveTranslatorPlugin: {
1414
install(app: any, options: LiveTranslatorPluginOptions): void;
1515
};

dist/index.js

Lines changed: 87 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import throttle from 'lodash/throttle';
2+
import forIn from 'lodash/forIn';
3+
import cloneDeep from 'lodash/cloneDeep';
4+
import set from 'lodash/set';
25
const css = `
36
.live-translator-enable-button {
47
position: fixed !important;
@@ -56,7 +59,50 @@ const css = `
5659
background: #00c0ff !important;
5760
box-shadow: 0px 0px 5px #00c0ff !important;
5861
}
62+
.live-translator-box {
63+
outline: solid 2px green;
64+
background: green;
65+
opacity: 0.1;
66+
position: absolute;
67+
border-radius: 4px;
68+
z-index: 9999;
69+
display: none;
70+
}
71+
.live-translator-box.attribute {
72+
outline: solid 2px blue;
73+
background: blue;
74+
}
5975
`;
76+
function deepForIn(object, fn) {
77+
const iteratee = (v, k) => {
78+
if (typeof v === 'object') {
79+
forIn(v, (childV, childK) => iteratee(childV, `${k}.${childK}`));
80+
}
81+
else {
82+
fn(v, k);
83+
}
84+
};
85+
forIn(object, iteratee);
86+
}
87+
export function encodeMessages(messagesObject) {
88+
const messages = cloneDeep(messagesObject);
89+
forIn(messages, (localeMessages, locale) => {
90+
deepForIn(localeMessages, (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(localeMessages, path, parts.join(' | '));
102+
});
103+
});
104+
return messages;
105+
}
60106
class ZeroWidthEncoder {
61107
static START = '\u200B';
62108
static ZERO = '\u200C';
@@ -110,6 +156,7 @@ class LiveTranslatorManager {
110156
_options;
111157
_enableButton;
112158
_indicator;
159+
_box;
113160
constructor(options) {
114161
this._enabled = false;
115162
this._options = options;
@@ -134,35 +181,14 @@ class LiveTranslatorManager {
134181
this._enableButton.appendChild(this._indicator);
135182
this._enableButton.addEventListener('click', () => {
136183
this.toggle();
137-
this.refreshI18n();
138184
this.render();
139185
});
140186
document.body.appendChild(this._enableButton);
187+
this._box = document.createElement('div');
188+
this._box.classList.add('live-translator-box');
189+
document.body.appendChild(this._box);
141190
// initialize encode
142-
const originalFormatter = this._options.i18n.formatter;
143-
const self = this;
144-
this._options.i18n.formatter = {
145-
interpolate(message, values, path) {
146-
const original = originalFormatter.interpolate(message, values, path);
147-
let meta = '';
148-
try {
149-
// filter nested objects, replace inner objects with string 'object'
150-
// this is needed when values from <i18n> tags are circular dependent objects
151-
const filteredValues = Object.fromEntries(Object.entries(values || {})
152-
.map(([key, value]) => [key, typeof value !== 'object' ? value : 'object']));
153-
meta = ZeroWidthEncoder.encode(JSON.stringify({
154-
message,
155-
values: filteredValues,
156-
path,
157-
locale: self._options.i18n.locale,
158-
}));
159-
}
160-
catch (exception) {
161-
console.warn(message, values, path, self._options.i18n.locale, exception);
162-
}
163-
return (original && meta && self._enabled) ? [meta, ...original] : original;
164-
},
165-
};
191+
// encode is moved to i18n.ts file
166192
// initialize decode & render
167193
const throttler = throttle(() => this.render(), 800);
168194
const observer = new MutationObserver(throttler);
@@ -173,15 +199,10 @@ class LiveTranslatorManager {
173199
childList: false,
174200
});
175201
document.documentElement.addEventListener('mousemove', throttler);
202+
window.setInterval(throttler, 1000);
176203
// render for the first time
177-
this.refreshI18n();
178204
this.render();
179205
}
180-
refreshI18n() {
181-
const originalLocale = this._options.i18n.locale;
182-
this._options.i18n.locale = '';
183-
this._options.i18n.locale = originalLocale;
184-
}
185206
toggle(enable) {
186207
if (enable !== undefined) {
187208
this._enabled = enable;
@@ -196,6 +217,7 @@ class LiveTranslatorManager {
196217
}
197218
render() {
198219
const badgeWrappers = document.querySelectorAll('.live-translator-badge-wrapper');
220+
this._box.style.display = 'none';
199221
badgeWrappers.forEach((wrapper) => {
200222
wrapper.remove();
201223
});
@@ -209,11 +231,14 @@ class LiveTranslatorManager {
209231
const node = queue.pop();
210232
const badges = [];
211233
const parent = node.parentElement;
234+
const rect = getBoundingClientRect(node);
212235
if (node instanceof Text) {
213236
const matches = node.textContent.match(re);
214237
for (const match of matches ?? []) {
215238
const meta = JSON.parse(ZeroWidthEncoder.decode(match));
216-
badges.push(createBadge(meta, this._options));
239+
const badge = createBadge(meta, this._options, node);
240+
badge.addEventListener('mouseenter', () => this.showBox(node));
241+
badges.push(badge);
217242
}
218243
}
219244
const attributes = (node.attributes ? [...node.attributes] : [])
@@ -222,7 +247,9 @@ class LiveTranslatorManager {
222247
for (const { attribute, match } of attributes) {
223248
for (const m of match) {
224249
const meta = JSON.parse(ZeroWidthEncoder.decode(m));
225-
badges.push(createBadge(meta, this._options, attribute.name));
250+
const badge = createBadge(meta, this._options, node, attribute.name);
251+
badge.addEventListener('mouseenter', () => this.showBox(node, true));
252+
badges.push(badge);
226253
}
227254
}
228255
if (badges.length) {
@@ -231,8 +258,11 @@ class LiveTranslatorManager {
231258
container = node.previousElementSibling;
232259
}
233260
else {
261+
const parentRect = getBoundingClientRect(node instanceof Text ? parent : node);
234262
container = document.createElement('span');
235263
container.classList.add('live-translator-badge-container');
264+
container.style.top = rect.top - parentRect.top + 'px';
265+
container.style.left = rect.left - parentRect.left + 'px';
236266
const relativeWrapper = document.createElement('span');
237267
relativeWrapper.classList.add('live-translator-badge-wrapper');
238268
relativeWrapper.appendChild(container);
@@ -247,8 +277,26 @@ class LiveTranslatorManager {
247277
}
248278
}
249279
}
280+
showBox(node, attribute = false) {
281+
const rect = !attribute ? getBoundingClientRect(node) : node.getClientRects()[0];
282+
if (!rect) {
283+
return;
284+
}
285+
if (attribute) {
286+
this._box.classList.add('attribute');
287+
}
288+
else {
289+
this._box.classList.remove('attribute');
290+
}
291+
const padding = 2;
292+
this._box.style.top = rect.top - padding + window.scrollY + 'px';
293+
this._box.style.left = rect.left - padding + window.scrollX + 'px';
294+
this._box.style.width = rect.width + 2 * padding + 'px';
295+
this._box.style.height = rect.height + 2 * padding + 'px';
296+
this._box.style.display = 'block';
297+
}
250298
}
251-
const createBadge = (meta, options, attribute) => {
299+
const createBadge = (meta, options, node, attribute) => {
252300
const badge = document.createElement('a');
253301
badge.classList.add('live-translator-badge');
254302
let title = meta.path + ': ' + meta.message;
@@ -269,6 +317,11 @@ const createBadge = (meta, options, attribute) => {
269317
});
270318
return badge;
271319
};
320+
function getBoundingClientRect(node, textOffset) {
321+
const range = document.createRange();
322+
range.selectNodeContents(node);
323+
return range.getBoundingClientRect();
324+
}
272325
export const LiveTranslatorPlugin = {
273326
install(app, options) {
274327
console.log('LiveTranslator is installed');

index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Vue i18n Live Translator plugin</title>
7+
</head>
8+
<body>
9+
<div id="app"></div>
10+
<script type="module" src="/src/demo/main.ts"></script>
11+
</body>
12+
</html>

0 commit comments

Comments
 (0)