Skip to content

Commit 9482b6d

Browse files
author
Sebi Nemeth
committed
init
0 parents  commit 9482b6d

File tree

4 files changed

+454
-0
lines changed

4 files changed

+454
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/

index.ts

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import Vue, { VueConstructor } from 'vue'
2+
import VueI18n from 'vue-i18n'
3+
import _ from 'lodash'
4+
5+
const css = `
6+
.live-translator-enable-button {
7+
position: fixed !important;
8+
top: 0;
9+
left: 0;
10+
z-index: 10000;
11+
padding: 2px;
12+
color: black;
13+
background: rgba(255, 255, 255, 0.6);
14+
font-family: sans-serif;
15+
font-size: 8px;
16+
}
17+
.live-translator-enable-button:hover {
18+
background: white;
19+
}
20+
.live-translator-enable-button-indicator {
21+
display: inline-block;
22+
height: 6px;
23+
width: 6px;
24+
margin-left: 2px;
25+
border-radius: 100%;
26+
background-color: red;
27+
}
28+
.live-translator-badge-container {
29+
position: absolute !important;
30+
display: flex;
31+
z-index: 10000;
32+
}
33+
.live-translator-badge {
34+
width: 10px !important;
35+
height: 10px !important;
36+
border-radius: 10px !important;
37+
box-shadow: 0px 0px 5px black !important;
38+
opacity: 0.5 !important;
39+
}
40+
.live-translator-badge:hover {
41+
opacity: 1 !important;
42+
}
43+
.live-translator-badge.text {
44+
background: green !important;
45+
}
46+
.live-translator-badge.text:hover {
47+
background: lightgreen !important;
48+
box-shadow: 0px 0px 5px lightgreen !important;
49+
}
50+
.live-translator-badge.attribute {
51+
background: blue !important;
52+
}
53+
.live-translator-badge.attribute:hover {
54+
background: #00c0ff !important;
55+
box-shadow: 0px 0px 5px #00c0ff !important;
56+
}
57+
`
58+
59+
class ZeroWidthEncoder {
60+
START = '\u200B'
61+
ZERO = '\u200C'
62+
ONE = '\u200D'
63+
SPACE = '\u200E'
64+
END = '\u200F'
65+
66+
encode (text: string) {
67+
const binary = text
68+
.split('')
69+
.map((char) => char.charCodeAt(0).toString(2))
70+
.join(' ')
71+
72+
const zeroWidth = binary
73+
.split('')
74+
.map((binaryNum) => {
75+
const num = parseInt(binaryNum, 10)
76+
if (num === 1) {
77+
return this.ONE
78+
} else if (num === 0) {
79+
return this.ZERO
80+
}
81+
return this.SPACE
82+
})
83+
.join('')
84+
return this.START + zeroWidth + this.END
85+
}
86+
87+
decode (zeroWidth: string) {
88+
const binary = zeroWidth
89+
.split('')
90+
.slice(1, zeroWidth.length - 1) // remove START and END
91+
.map((char) => {
92+
if (char === this.ONE) {
93+
return '1'
94+
} else if (char === this.ZERO) {
95+
return '0'
96+
}
97+
return ' '
98+
})
99+
.join('')
100+
101+
const text = binary
102+
.split(' ')
103+
.map((num) => String.fromCharCode(parseInt(num, 2)))
104+
.join('')
105+
return text
106+
}
107+
}
108+
109+
class LiveTranslatorEnabler {
110+
_enabled: boolean
111+
persist: boolean
112+
113+
constructor (persist: boolean) {
114+
this._enabled = false
115+
this.persist = persist
116+
const savedRaw = localStorage.getItem('live-translator-enabled')
117+
if (persist && savedRaw) {
118+
const saved = JSON.parse(savedRaw)
119+
if (typeof saved === 'boolean') {
120+
this.toggle(saved)
121+
}
122+
}
123+
}
124+
125+
enabled () {
126+
return this._enabled
127+
}
128+
129+
toggle (enable?: boolean) {
130+
if (enable !== undefined) {
131+
this._enabled = enable
132+
} else {
133+
this._enabled = !this._enabled
134+
}
135+
if (this.persist) {
136+
localStorage.setItem('live-translator-enabled', JSON.stringify(this._enabled))
137+
}
138+
}
139+
}
140+
141+
export type TranslationMeta = {
142+
locale: string,
143+
message: string,
144+
values: unknown,
145+
path: string,
146+
}
147+
148+
type LiveTranslatorPluginOptions = {
149+
i18n: VueI18n
150+
translationLink: (meta: TranslationMeta) => string
151+
persist?: boolean
152+
}
153+
154+
const createBadge = (meta: TranslationMeta, options: LiveTranslatorPluginOptions, attribute?: string) => {
155+
const badge = document.createElement('a')
156+
badge.classList.add('live-translator-badge')
157+
let title = meta.path + ': ' + meta.message
158+
if (attribute) {
159+
title = `[${attribute}] ${title}`
160+
badge.classList.add('attribute')
161+
} else {
162+
badge.classList.add('text')
163+
}
164+
badge.title = title
165+
badge.href = options.translationLink(meta)
166+
badge.target = 'popup'
167+
badge.addEventListener('click', (e: Event) => {
168+
window.open(badge.href, 'popup', 'width=600,height=600,scrollbars=no,resizable=no')
169+
e.preventDefault()
170+
return false
171+
})
172+
return badge
173+
}
174+
175+
export const LiveTranslatorPlugin = {
176+
install (app: VueConstructor<Vue>, options: LiveTranslatorPluginOptions) {
177+
console.log('LiveTranslator is installed')
178+
const zw = new ZeroWidthEncoder()
179+
const ltEnabler = new LiveTranslatorEnabler(options.persist || false)
180+
181+
const enableButton = document.createElement('button')
182+
enableButton.innerText = 'LT'
183+
enableButton.classList.add('live-translator-enable-button')
184+
const indicator = document.createElement('span')
185+
indicator.classList.add('live-translator-enable-button-indicator')
186+
enableButton.appendChild(indicator)
187+
enableButton.addEventListener('click', () => {
188+
ltEnabler.toggle()
189+
visualize()
190+
// Refresh translations to show immediately
191+
const originalLocale = options.i18n.locale
192+
options.i18n.locale = ''
193+
options.i18n.locale = originalLocale
194+
})
195+
document.body.appendChild(enableButton)
196+
197+
const style = document.createElement('style')
198+
style.id = 'live-translator-plugin-style'
199+
style.innerHTML = css
200+
document.head.appendChild(style)
201+
202+
const visualize = () => {
203+
const badges = document.querySelectorAll('.live-translator-badge')
204+
badges.forEach((badge) => {
205+
badge.remove()
206+
})
207+
208+
indicator.style.background = ltEnabler.enabled() ? 'lightgreen' : 'red'
209+
210+
if (!ltEnabler.enabled()) {
211+
return
212+
}
213+
214+
const re = new RegExp(`${zw.START}[${zw.ZERO}${zw.ONE}${zw.SPACE}]+${zw.END}`, 'gm')
215+
216+
const queue = [document.documentElement] as Node[]
217+
while (queue.length > 0) {
218+
const node = queue.pop() as HTMLElement
219+
220+
const badges = [] as HTMLElement[]
221+
const parent = node.parentElement as Element
222+
223+
if (node instanceof Text) {
224+
const matches = (node.textContent as string).match(re)
225+
for (const match of matches ?? []) {
226+
const meta = JSON.parse(zw.decode(match)) as TranslationMeta
227+
badges.push(createBadge(meta, options))
228+
}
229+
}
230+
231+
const attributes = (node.attributes ? [...node.attributes] : [])
232+
.map((attribute) => ({ attribute, match: attribute.value.match(re) }))
233+
.filter(({ match }) => !!match)
234+
for (const { attribute, match } of attributes) {
235+
for (const m of (match as RegExpMatchArray)) {
236+
const meta = JSON.parse(zw.decode(m)) as TranslationMeta
237+
badges.push(createBadge(meta, options, attribute.name))
238+
}
239+
}
240+
241+
if (badges.length) {
242+
let container
243+
if (node.previousElementSibling && node.previousElementSibling.classList.contains('live-translator-badge-container')) {
244+
container = node.previousElementSibling
245+
} else {
246+
container = document.createElement('span')
247+
container.classList.add('live-translator-badge-container')
248+
parent.insertBefore(container, node)
249+
}
250+
for (const badge of badges) {
251+
container.appendChild(badge)
252+
}
253+
}
254+
255+
for (const child of node.childNodes) {
256+
queue.push(child)
257+
}
258+
}
259+
}
260+
261+
const originalFormatter = options.i18n.formatter
262+
options.i18n.formatter = {
263+
interpolate (message, values, path) {
264+
const meta = zw.encode(
265+
JSON.stringify({
266+
message,
267+
values,
268+
path,
269+
locale: options.i18n.locale,
270+
}),
271+
)
272+
const original = originalFormatter.interpolate(message, values, path) as unknown[] | null
273+
return (original && ltEnabler.enabled()) ? [meta, ...original] : original
274+
},
275+
}
276+
277+
const throttler = _.throttle(visualize, 800)
278+
const observer = new MutationObserver(throttler)
279+
observer.observe(document.documentElement,
280+
{
281+
subtree: true,
282+
attributes: true,
283+
characterData: true,
284+
childList: false,
285+
},
286+
)
287+
document.documentElement.addEventListener('mousemove', throttler)
288+
},
289+
}

0 commit comments

Comments
 (0)