15 min read

Internationalization, often abbreviated as i18n, implies a particular software design capable of adapting to the requirements of target local markets. In other words if we want to distribute our application to the markets other than USA we need to take care of translations, formatting of datetime, numbers, addresses, and such. In today’s post we will cover the concept of implementing Internationalization and localization in the Node.js app and look at context menu and system clipboard in detail.

Date format by country

Internationalization is a cross-cutting concern. When you are changing the locale it usually affects multiple modules. So I suggest going with the observer pattern that we already examined while working on DirService’. The ./js/Service/I18n.jsfile contains the following code:

constEventEmitter=require("events");

classI18nServiceextendsEventEmitter{

constructor(){

super();

this.locale="en-US";

}

notify(){

this.emit("update");

}

}

exports.I18nService=I18nService;

As you see, we can change the locale by setting a new value to localeproperty. As soon as we call notifymethod, then all the subscribed modules immediately respond.

But localeis a public property and therefore we have no control on its access and mutation. We can fix it by using overloading. The ./js/Service/I18n.jsfile contains the following code:

//...constructor(){

super();

this._locale="en-US";

}

getlocale(){

returnthis._locale;

}

setlocale(locale){

//validatelocale...this._locale=locale;

}

//...

Now if we access localeproperty of I18ninstance it gets delivered by the getter (getlocale). When setting it a value, it goes through the setter (setlocale). Thus we can add extra functionality such as validation and logging on property access and mutation.

Remember we have in the HTML, a combobox for selecting language. Why not give it a view? The ./js/View/LangSelector.jfile contains the following code:

classLangSelectorView{

constructor(boundingEl,i18n){

boundingEl.addEventListener("change",this.onChanged.bind(this),false);

this.i18n=i18n;

}

onChanged(e){

constselectEl=e.target;this.i18n.locale=selectEl.value;this.i18n.notify();

}

}

exports.LangSelectorView=LangSelectorView;

In the preceding code, we listen for change events on the combobox.

When the event occurs we change localeproperty of the passed in I18ninstance and call
notifyto inform the subscribers. The ./js/app.jsfile contains the following code:

consti18nService=newI18nService(),

{LangSelectorView}=require("./js/View/LangSelector");

newLangSelectorView(document.querySelector("[data-bind=langSelector]"),i18nService);

Well, we can change the locale and trigger the event. What about consuming modules? In FileListview we have static method formatTimethat formats the passed in timeStringfor printing. We can make it formated in accordance with currently chosen locale. The ./js/View/FileList.jsfile contains the following code:

constructor(boundingEl,dirService,i18nService){

//...

this.i18n=i18nService;

//Subscribeoni18nServiceupdates

i18nService.on("update",()=>this.update(

dirService.getFileList()));

}

staticformatTime(timeString,locale){

constdate=newDate(Date.parse(timeString)),options={

year:"numeric",month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",second:"numeric",hour12:false

};

returndate.toLocaleString(locale,options);

}

update(collection){

//...

this.el.insertAdjacentHTML("beforeend",`<liclass="file-listli"data-file="${fInfo.fileName}">

<spanclass="file-listliname">${fInfo.fileName}</span>

<spanclass="file-

listlisize">${filesize(fInfo.stats.size)}</span>

<spanclass="file-listlitime">${FileListView.formatTime(

fInfo.stats.mtime,this.i18n.locale)}</span>

</li>`);

//...

}

//...

In the constructor, we subscribe for I18nupdate event and update the file list every time the locale changes. Static method formatTimeconverts passed in string into a Dateobject and uses Date.prototype.toLocaleString()method to format the datetime according to a given locale. This method belongs to so called ECMAScript Internationalization API
(http://norbertlindenberg.com/2012/12/ecmascript-internationalization-api/index.html). The API describes methods of built-in object String, Dateand Numberdesigned to format and compare localized data. But what it really does is formatting a Dateinstance with toLocaleStringfor the English (United States) locale (“en-US”) and it returns the date as follows:

3/17/2017, 13:42:23

However if we feed to the method German locale (“de-DE”) we get quite a different result:

17.3.2017, 13:42:23

To put it into action we set an identifier to the combobox. The ./index.htmlfile contains the following code:

..

<selectclass="footerselect"data-bind="langSelector">

..

And of course, we have to create an instance of I18nservice and pass it in LangSelectorViewand FileListView:

./js/app.js

//...

const{I18nService}=require("./js/Service/I18n"),

{LangSelectorView}=require("./js/View/LangSelector"),i18nService=newI18nService();

newLangSelectorView(document.querySelector("[data-bind=langSelector]"),i18nService);

//...

newFileListView(document.querySelector("[data-bind=fileList]"),dirService,i18nService);

Now we start the application. Yeah! As we change the language in the combobox the file modification dates adjust accordingly:

Multilingual support

Localization dates and number is a good thing, but it would be more exciting to provide translation to multiple languages. We have a number of terms across the application, namely the column titles of the file list and tooltips (via titleattribute) on windowing action buttons. What we need is a dictionary. Normally it implies sets of token translation pairs mapped to language codes or locales. Thus when you request from the translation service a term, it can correlate to a matching translation according to currently used language/locale. Here I have suggested making the dictionary as a static module that can be loaded with the required function.

The ./js/Data/dictionary.jsfile contains the following code:

exports.dictionary={"en-US":{

NAME:"Name",SIZE:"Size",MODIFIED:"Modified",

MINIMIZE_WIN:"Minimizewindow",

RESTORE_WIN:"Restorewindow",MAXIMIZE_WIN:"Maximizewindow",CLOSE_WIN:"Closewindow"

},

"de-DE":{

NAME:"Dateiname",SIZE:"Grösse",

MODIFIED:"Geändertam",MINIMIZE_WIN:"Fensterminimieren",RESTORE_WIN:"Fensterwiederherstellen",MAXIMIZE_WIN:"Fenstermaximieren",

CLOSE_WIN:"Fensterschliessen"

}

};

So we have two locales with translations per term. We are going to inject the dictionary as a dependency into our I18nservice.

The ./js/Service/I18n.jsfile contains the following code:

//...

constructor(dictionary){

super();

this.dictionary=dictionary;

this._locale="en-US";

}

translate(token,defaultValue){

constdictionary=this.dictionary[this._locale];

returndictionary[token]||defaultValue;

}

//...

We also added a new method translate that accepts two parameters: tokenand defaulttranslation. The first parameter can be one of the keys from the dictionary like NAME. The second one is guarding value for the case when requested token does not yet exist in the dictionary. Thus we still get a meaningful text at least in English.

Let’s see how we can use this new method. The ./js/View/FileList.jsfile contains the following code:

//...

update(collection){

this.el.innerHTML=`<liclass="file-listlifile-listhead">

<spanclass="file-listliname">${this.i18n.translate("NAME","Name")}</span>

<spanclass="file-listlisize">${this.i18n.translate("SIZE",

"Size")}</span>

<spanclass="file-listlitime">${this.i18n.translate("MODIFIED","Modified")}</span>

</li>`;

//...

We change in FileListview hardcoded column titles with calls for translatemethod of I18ninstance, meaning that every time view updates it receives the actual translations. We shall not forget about TitleBarActionsview where we have windowing action buttons. The ./js/View/TitleBarActions.jsfile contains the following code:

constructor(boundingEl,i18nService){

this.i18n=i18nService;

//...

//Subscribeoni18nServiceupdates

i18nService.on("update",()=>this.translate());

}

translate(){

this.unmaximizeEl.title=this.i18n.translate("RESTORE_WIN","Restorewindow");

this.maximizeEl.title=this.i18n.translate("MAXIMIZE_WIN","Maximizewindow");

this.minimizeEl.title=this.i18n.translate("MINIMIZE_WIN","Minimizewindow");

this.closeEl.title=this.i18n.translate("CLOSE_WIN","Closewindow");

}

Here we add method translate, which updates button title attributes with actual translations. We subscribe for i18nupdate event to call the method every time user changes locale:

Context menu

Well, with our application we can already navigate through the file system and open files. Yet, one might expect more of a File Explorer. We can add some file related actions like delete, copy/paste. Usually these tasks are available via the context menu, what gives us a good opportunity to examine how to make it with NW.js. With the environment integration API we can create an instance of system menu (http://docs.nwjs.io/en/latest/References/Menu/). Then we compose objects representing menu items and attach them to the menu instance (http://docs.nwjs.io/en/latest/References/MenuItem/). This menucan be shown in an arbitrary position:

constmenu=newnw.Menu(),menutItem=newnw.MenuItem({

label:"Sayhello",

click:()=>console.log("hello!")

});

menu.append(menu);

menu.popup(10,10);

Yet our task is more specific. We have to display the menu on the right mouse click in the position of the cursor. That is, we achieve by subscribing a handler to contextmenuDOM event:

document.addEventListener("contextmenu",(e)=>{

console.log(`Showmenuinposition${e.x},${e.y}`);

});

Now whenever we right-click within the application window the menu shows up. It’s not exactly what we want, isn’t it? We need it only when the cursor resides within a particular region. For an instance, when it hovers a file name. That means we have to test if the target element matches our conditions:

document.addEventListener("contextmenu",(e)=>{

constel=e.target;

if(elinstanceofHTMLElement&&el.parentNode.dataset.file){

console.log(`Showmenuinposition${e.x},${e.y}`);

}

});

Here we ignore the event until the cursor hovers any cell of file table row, given every row is a list item generated by FileListview and therefore provided with a value for data file attribute.

This passage explains pretty much how to build a system menu and how to attach it to the file list. But before starting on a module capable of creating menu, we need a service to handle file operations.

The ./js/Service/File.jsfile contains the following code:

constfs=require("fs"),

path=require("path"),

//Copyfilehelper

cp=(from,toDir,done)=>{

constbasename=path.basename(from),to=path.join(toDir,basename),write=fs.createWriteStream(to);

fs.createReadStream(from)

.pipe(write);

write

.on("finish",done);

};

classFileService{

constructor(dirService){this.dir=dirService;this.copiedFile=null;

}

remove(file){

fs.unlinkSync(this.dir.getFile(file));

this.dir.notify();

}

paste(){

constfile=this.copiedFile;

if(fs.lstatSync(file).isFile()){

cp(file,this.dir.getDir(),()=>this.dir.notify());

}

}

copy(file){

this.copiedFile=this.dir.getFile(file);

}

open(file){

nw.Shell.openItem(this.dir.getFile(file));

}

showInFolder(file){

nw.Shell.showItemInFolder(this.dir.getFile(file));

}

};

exports.FileService=FileService;

What’s going on here? FileServicereceives an instance of DirServiceas a constructor argument. It uses the instance to obtain the full path to a file by name ( this.dir.getFile(file)). It also exploits notifymethod of the instance to request all the views subscribed to DirServiceto update. Method showInFoldercalls the corresponding method of nw.Shellto show the file in the parent folder with the system file manager. As you can recon method removedeletes the file. As for copy/paste we do the following trick. When user clicks copy we store the target file path in property copiedFile. So when user next time clicks paste we can use it to copy that file to the supposedly changed current location. Method openevidently opens file with the default associated program. That is what we do in FileListview directly. Actually this action belongs to FileService. So we rather refactor the view to use the service. The ./js/View/FileList.jsfile contains the following code:

constructor(boundingEl,dirService,i18nService,fileService){

this.file=fileService;

//...

}

bindUi(){

//...

this.file.open(el.dataset.file);

//...

}

Now we have a module to handle context menu for a selected file. The module will subscribe for contextmenuDOM event and build a menu when user right clicks on a file. This menu will contain items Show Item in the Folder, Copy, Paste, and Delete. Whereas copy and paste are separated from other items with delimiters. Besides, Paste will be disabled until we store a file with copy. Further goes the source code. The

./js/View/ContextMenu.jsfile contains the following code:

classConextMenuView{

constructor(fileService,i18nService){

this.file=fileService;this.i18n=i18nService;this.attach();

}

getItems(fileName){

constfile=this.file,

isCopied=Boolean(file.copiedFile);

return[

{

label:this.i18n.translate("SHOW_FILE_IN_FOLDER","ShowItemintheFolder"),

enabled:Boolean(fileName),

click:()=>file.showInFolder(fileName)

},

{

type:"separator"

},

{

label:this.i18n.translate("COPY","Copy"),enabled:Boolean(fileName),

click:()=>file.copy(fileName)

},

{

label:this.i18n.translate("PASTE","Paste"),enabled:isCopied,

click:()=>file.paste()

},

{

type:"separator"

},

{

label:this.i18n.translate("DELETE","Delete"),enabled:Boolean(fileName),

click:()=>file.remove(fileName)

}

];

}

render(fileName){

constmenu=newnw.Menu();

this.getItems(fileName).forEach((item)=>menu.append(newnw.MenuItem(item)));

returnmenu;

}

attach(){

document.addEventListener("contextmenu",(e)=>{

constel=e.target;

if(!(elinstanceofHTMLElement)){

return;

}

if(el.classList.contains("file-list")){

e.preventDefault();

this.render()

.popup(e.x,e.y);

}

//Ifachildofanelementmatching[data-file]

if(el.parentNode.dataset.file){

e.preventDefault();

this.render(el.parentNode.dataset.file)

.popup(e.x,e.y);

}

});

}

}

exports.ConextMenuView=ConextMenuView;

So in ConextMenuViewconstructor, we receive instances of FileServiceand I18nService. During the construction we also call attach method that subscribes for contextmenuDOM event, creates the menu and shows it in the position of the mouse cursor. The event gets ignored unless the cursor hovers a file or resides in empty area of the file list component. When user right clicks the file list, the menu still appears, but with all items disable except paste (in case a file was copied before).

Method render create an instance of menu and populates it with nw.MenuItemscreated by getItemsmethod. The method creates an array representing menu items. Elements of the array are object literals.

Property labelaccepts translation for item caption. Property enableddefines the state of item depending on our cases (whether we have copied file or not, whether the cursor on a file or not). Finally property clickexpects the handler for click event.

Now we need to enable our new components in the main module. The ./js/app.jsfile contains the following code:

const{FileService}=require("./js/Service/File"),

{ConextMenuView}=require("./js/View/ConextMenu"),fileService=newFileService(dirService);

newFileListView(document.querySelector("[data-bind=fileList]"),dirService,i18nService,fileService);

newConextMenuView(fileService,i18nService);

Let’s now run the application, right-click on a file and voilà! We have the context menu and new file actions.

System clipboard

Usually Copy/Paste functionality involves system clipboard. NW.jsprovides an API to control it (http://docs.nwjs.io/en/latest/References/Clipboard/). Unfortunately it’s quite limited, we cannot transfer an arbitrary file between applications, what you may expect of a file manager. Yet some things we are still available to us.

Transferring text

In order to examine text transferring with the clipboard we modify the method copy of

FileService:

copy(file){

this.copiedFile=this.dir.getFile(file);constclipboard=nw.Clipboard.get();clipboard.set(this.copiedFile,"text");

}

What does it do? As soon as we obtained file full path, we create an instance of nw.Clipboardand save the file path there as a text. So now, after copying a file within the File Explorer we can switch to an external program (for example, a text editor) and paste the copied path from the clipboard.

Transferring graphics

It doesn’t look very handy, does it? It would be more interesting if we could copy/paste a file. Unfortunately NW.jsdoesn’t give us many options when it comes to file exchange. Yet we can transfer between NW.jsapplication and external programs PNG and JPEG images. The ./js/Service/File.jsfile contains the following code:

//...

copyImage(file,type){

constclip=nw.Clipboard.get(),

//loadfilecontentasBase64

data=fs.readFileSync(file).toString("base64"),

//imageasHTML

html=`<imgsrc="file:///${encodeURI(data.replace(/^//,"")

)}">`;

//writebothoptions(rawimageandHTML)totheclipboardclip.set([

{type,data:data,raw:true},

{type:"html",data:html}

]);

}

copy(file){

this.copiedFile=this.dir.getFile(file);

constext=path.parse(this.copiedFile).ext.substr(1);

switch(ext){case"jpg":case"jpeg":

returnthis.copyImage(this.copiedFile,"jpeg");

case"png":

returnthis.copyImage(this.copiedFile,"png");

}

}

//...

We extended our FileServicewith private method copyImage. It reads a given file, converts its contents in Base64 and passes the resulting code in a clipboard instance. In addition, it creates HTML with image tag with Base64-encoded image in data Uniform Resource Identifier (URI). Now after copying an image (PNG or JPEG) in the File Explorer, we can paste it in an external program such as graphical editor or text processor.

Receiving text and graphics

We’ve learned how to pass a text and graphics from our NW.jsapplication to external programs. But how can we receive data from outside? As you can guess it is accessible through get method of nw.Clipboard. Text can be retrieved that simple:

constclip=nw.Clipboard.get();

console.log(clip.get("text"));

When graphic is put in the clipboard we can get it with NW.js only as Base64-encoded content or as HTML. To see it in practice we add a few methods to FileService. The

./js/Service/File.jsfile contains the following code:

//...hasImageInClipboard(){

constclip=nw.Clipboard.get();

returnclip.readAvailableTypes().indexOf("png")!==-1;

}

pasteFromClipboard(){

constclip=nw.Clipboard.get();

if(this.hasImageInClipboard()){

constbase64=clip.get("png",true),

binary=Buffer.from(base64,"base64"),filename=Date.now()+"--img.png";

fs.writeFileSync(this.dir.getFile(filename),binary);

this.dir.notify();

}

}

//...

Method hasImageInClipboardchecks if the clipboard keeps any graphics. Method pasteFromClipboardtakes graphical content from the clipboard as Base64-encoded PNG. It converts the content into binary code, writes into a file and requests DirServicesubscribers to update.

To make use of these methods we need to edit ContextMenuview. The ./js/View/ContextMenu.jsfile contains the following code:

getItems(fileName){

constfile=this.file,

isCopied=Boolean(file.copiedFile);

return[

//...

{

label:this.i18n.translate("PASTE_FROM_CLIPBOARD","Pasteimagefromclipboard"),

enabled:file.hasImageInClipboard(),

click:()=>file.pasteFromClipboard()

},

//...

];

}

We add to the menu a new item Pasteimagefromclipboard, which is enabled only when there is any graphic in the clipboard.

[box type=”shadow” align=”” class=”” width=””]This article is an excerpt from the book Cross Platform Desktop Application Development: Electron, Node, NW.js and React written by Dmitry Sheiko. This book will help you build powerful cross-platform desktop applications with web technologies such as Node, NW.JS, Electron, and React.[/box]

Cross-platform Desktop Application Development: Electron, Node, NW.js, and React Book Cover

Read More:

How to deploy a Node.js application to the web using Heroku

How is Node.js Changing Web Development?

With Node.js, it’s easy to get things done

 

LEAVE A REPLY

Please enter your comment!
Please enter your name here