In this article by Dmitry Sheiko, the author of the book, Cross Platform Desktop Application Development: Electron, Node, NW.js and React, will cover the concept of Internationalization and localization and will be also covering context menu and system clipboard in detail.
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.
(For more resources related to this topic, see here.)
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.js file contains the following code:
const EventEmitter = require( "events" );
class I18nService extends EventEmitter {
constructor(){
super();
this.locale = "en-US";
}
Internationalization and localization
[ 2 ]
notify(){
this.emit( "update" );
}
}
As you see, we can change the locale by setting a new value to locale property. As soon as we call notify method, then all the subscribed modules immediately respond. But locale is 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.js file contains the following code:
//...
constructor(){
super();
this._locale = "en-US";
}
get locale(){
return this._locale;
}
set locale( locale ){
// validate locale...
this._locale = locale;
}
//...
Now if we access locale property of I18n instance it gets delivered by the getter (get locale). When setting it a value, it goes through the setter (set locale). 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.j file contains the following code:
class LangSelectorView {
constructor( boundingEl, i18n ){
boundingEl.addEventListener( "change", this.onChanged.bind( this ),
false );
this.i18n = i18n;
}
onChanged( e ){
const selectEl = e.target;
this.i18n.locale = selectEl.value;
this.i18n.notify();
}
}
Internationalization and localization
[ 3 ]
exports.LangSelectorView = LangSelectorView;
In the preceding code, we listen for change events on the combobox.
When the event occurs we change locale property of the passed in I18n instance and call notify to inform the subscribers. The ./js/app.js file contains the following code:
const i18nService = new I18nService(),
{ LangSelectorView } = require( "./js/View/LangSelector" );
new LangSelectorView( document.querySelector( "[data-bind=langSelector]" ),
i18nService );
Well, we can change the locale and trigger the event. What about consuming modules? In FileList view we have static method formatTime that formats the passed in timeString for printing. We can make it formated in accordance with currently chosen locale. The ./js/View/FileList.js file contains the following code:
constructor( boundingEl, dirService, i18nService ){
//...
this.i18n = i18nService;
// Subscribe on i18nService updates
i18nService.on( "update", () => this.update(
dirService.getFileList() ) );
}
static formatTime( timeString, locale ){
const date = new Date( Date.parse( timeString ) ),
options = {
year: "numeric", month: "numeric", day: "numeric",
hour: "numeric", minute: "numeric", second: "numeric",
hour12: false
};
return date.toLocaleString( locale, options );
}
update( collection ) {
//...
this.el.insertAdjacentHTML( "beforeend", `<li class="file-list__li"
data-file="${fInfo.fileName}">
<span class="file-list__li__name">${fInfo.fileName}</span>
<span class="filelist__li__size">${filesize(fInfo.stats.size)}</span>
<span class="file-list__li__time">${FileListView.formatTime(
fInfo.stats.mtime, this.i18n.locale )}</span>
</li>` );
//...
}
//...
In the constructor, we subscribe for I18n update event and update the file list every time the locale changes. Static method formatTime converts passed in string into a Date object and uses Date.prototype.toLocaleString() method to format the datetime according to a given locale. This method belongs to so called The ECMAScript Internationalization API
(http://norbertlindenberg.com/2012/12/ecmascript-internationalization-api/index .html). The API describes methods of built-in object String, Date and Number designed to format and compare localized data. But what it really does is formatting a Date instance with toLocaleString for 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.html file contains the following code:
..
<select class="footer__select" data-bind="langSelector">
..
And of course, we have to create an instance of I18n service and pass it in LangSelectorView and FileListView:
./js/app.js
// ...
const { I18nService } = require( "./js/Service/I18n" ),
{ LangSelectorView } = require( "./js/View/LangSelector" ),
i18nService = new I18nService();
new LangSelectorView( document.querySelector( "[data-bind=langSelector]" ),
i18nService );
// ...
new FileListView( 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 title attribute) 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.js file contains the following code:
exports.dictionary = {
"en-US": {
NAME: "Name",
SIZE: "Size",
MODIFIED: "Modified",
MINIMIZE_WIN: "Minimize window",
Internationalization and localization
[ 6 ]
RESTORE_WIN: "Restore window",
MAXIMIZE_WIN: "Maximize window",
CLOSE_WIN: "Close window"
},
"de-DE": {
NAME: "Dateiname",
SIZE: "Grösse",
MODIFIED: "Geändert am",
MINIMIZE_WIN: "Fenster minimieren",
RESTORE_WIN: "Fenster wiederherstellen",
MAXIMIZE_WIN: "Fenster maximieren",
CLOSE_WIN: "Fenster schliessen"
}
};
So we have two locales with translations per term. We are going to inject the dictionary as a dependency into our I18n service.
The ./js/Service/I18n.js file contains the following code:
//...
constructor( dictionary ){
super();
this.dictionary = dictionary;
this._locale = "en-US";
}
translate( token, defaultValue ) {
const dictionary = this.dictionary[ this._locale ];
return dictionary[ token ] || defaultValue;
}
//...
We also added a new method translate that accepts two parameters: token and default translation. 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.js file contains the following code:
//...
update( collection ) {
this.el.innerHTML = `<li class="file-list__li file-list__head">
<span class="file-list__li__name">${this.i18n.translate( "NAME",
"Name" )}</span>
<span class="file-list__li__size">${this.i18n.translate( "SIZE",
Internationalization and localization
[ 7 ]
"Size" )}</span>
<span class="file-list__li__time">${this.i18n.translate(
"MODIFIED", "Modified" )}</span>
</li>`;
//...
We change in FileList view hardcoded column titles with calls for translate method of I18n instance, meaning that every time view updates it receives the actual translations. We shall not forget about TitleBarActions view where we have windowing action buttons. The ./js/View/TitleBarActions.js file contains the following code:
constructor( boundingEl, i18nService ){
this.i18n = i18nService;
//...
// Subscribe on i18nService updates
i18nService.on( "update", () => this.translate() );
}
translate(){
this.unmaximizeEl.title = this.i18n.translate( "RESTORE_WIN", "Restore
window" );
this.maximizeEl.title = this.i18n.translate( "MAXIMIZE_WIN", "Maximize
window" );
this.minimizeEl.title = this.i18n.translate( "MINIMIZE_WIN", "Minimize
window" );
this.closeEl.title = this.i18n.translate( "CLOSE_WIN", "Close window" );
}
Here we add method translate, which updates button title attributes with actual translations. We subscribe for i18n update 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 menu can be shown in an arbitrary position:
const menu = new nw.Menu(),
menutItem = new nw.MenuItem({
label: "Say hello",
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 contextmenu DOM event:
document.addEventListener( "contextmenu", ( e ) => {
console.log( `Show menu in position ${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 ) => {
const el = e.target;
if ( el instanceof HTMLElement && el.parentNode.dataset.file ) {
console.log( `Show menu in position ${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 FileList view 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.js file contains the following code:
const fs = require( "fs" ),
path = require( "path" ),
// Copy file helper
cp = ( from, toDir, done ) => {
const basename = path.basename( from ),
to = path.join( toDir, basename ),
write = fs.createWriteStream( to ) ;
fs.createReadStream( from )
.pipe( write );
write
.on( "finish", done );
};
class FileService {
Internationalization and localization
[ 10 ]
constructor( dirService ){
this.dir = dirService;
this.copiedFile = null;
}
remove( file ){
fs.unlinkSync( this.dir.getFile( file ) );
this.dir.notify();
}
paste(){
const file = 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? FileService receives an instance of DirService as a constructor argument. It uses the instance to obtain the full path to a file by name ( this.dir.getFile( file ) ). It also exploits notify method of the instance to request all the views subscribed to DirService to update. Method showInFolder calls the corresponding method of nw.Shell to show the file in the parent folder with the system file manager. As you can recon method remove deletes 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 open evidently opens file with the default associated program. That is what we do in FileList view directly. Actually this action belongs to FileService. So we rather refactor the view to use the service. The ./js/View/FileList.js file contains the following code:
constructor( boundingEl, dirService, i18nService, fileService ){
this.file = fileService;
//...
}
Internationalization and localization
[ 11 ]
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 contextmenu DOM 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.js file contains the following code:
class ConextMenuView {
constructor( fileService, i18nService ){
this.file = fileService;
this.i18n = i18nService;
this.attach();
}
getItems( fileName ){
const file = this.file,
isCopied = Boolean( file.copiedFile );
return [
{
label: this.i18n.translate( "SHOW_FILE_IN_FOLDER", "Show Item in
the Folder" ),
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"
},
{
Internationalization and localization
[ 12 ]
label: this.i18n.translate( "DELETE", "Delete" ),
enabled: Boolean( fileName ),
click: () => file.remove( fileName )
}
];
}
render( fileName ){
const menu = new nw.Menu();
this.getItems( fileName ).forEach(( item ) => menu.append( new
nw.MenuItem( item )));
return menu;
}
attach(){
document.addEventListener( "contextmenu", ( e ) => {
const el = e.target;
if ( !( el instanceof HTMLElement ) ) {
return;
}
if ( el.classList.contains( "file-list" ) ) {
e.preventDefault();
this.render()
.popup( e.x, e.y );
}
// If a child of an element matching [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 ConextMenuView constructor, we receive instances of FileService and I18nService. During the construction we also call attach method that subscribes for contextmenu DOM 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.MenuItems created by getItems method. The method creates an array representing menu items. Elements of the array are object literals.
Internationalization and localization [ 13 ] Property label accepts translation for item caption. Property enabled defines 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 click expects the handler for click event.
Now we need to enable our new components in the main module. The ./js/app.js file contains the following code:
const { FileService } = require( "./js/Service/File" ),
{ ConextMenuView } = require( "./js/View/ConextMenu" ),
fileService = new FileService( dirService );
new FileListView( document.querySelector( "[data-bind=fileList]" ),
dirService, i18nService, fileService );
new ConextMenuView( 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.js provides 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 );
const clipboard = 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.Clipboard and 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.js doesn't give us many options when it comes to file exchange. Yet we can transfer between NW.js application and external programs PNG and JPEG images. The ./js/Service/File.js file contains the following code:
//...
copyImage( file, type ){
const clip = nw.Clipboard.get(),
// load file content as Base64
data = fs.readFileSync( file ).toString( "base64" ),
// image as HTML
html = `<img src="file:///${encodeURI( data.replace( /^//, "" )
)}">`;
// write both options (raw image and HTML) to the clipboard
clip.set([
Internationalization and localization
[ 16 ]
{ type, data: data, raw: true },
{ type: "html", data: html }
]);
}
copy( file ){
this.copiedFile = this.dir.getFile( file );
const ext = path.parse( this.copiedFile ).ext.substr( 1 );
switch ( ext ){
case "jpg":
case "jpeg":
return this.copyImage( this.copiedFile, "jpeg" );
case "png":
return this.copyImage( this.copiedFile, "png" );
}
}
//...
We extended our FileService with 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.js application 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:
const clip = 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.js file contains the following code:
//...
hasImageInClipboard(){
const clip = nw.Clipboard.get();
return clip.readAvailableTypes().indexOf( "png" ) !== -1;
}
pasteFromClipboard(){
const clip = nw.Clipboard.get();
if ( this.hasImageInClipboard() ) {
Internationalization and localization
[ 17 ]
const base64 = 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 hasImageInClipboard checks if the clipboard keeps any graphics. Method pasteFromClipboard takes graphical content from the clipboard as Base64-encoded PNG. It converts the content into binary code, writes into a file and requests DirService subscribers to update.
To make use of these methods we need to edit ContextMenu view. The ./js/View/ContextMenu.js file contains the following code:
getItems( fileName ){
const file = this.file,
isCopied = Boolean( file.copiedFile );
return [
//...
{
label: this.i18n.translate( "PASTE_FROM_CLIPBOARD", "Paste image
from clipboard" ),
enabled: file.hasImageInClipboard(),
click: () => file.pasteFromClipboard()
},
//...
];
}
We add to the menu a new item Paste image from clipboard, which is enabled only when there is any graphic in the clipboard.
Summary
In this article, we have covered concept of internationalization and localization and also covered context menu and system clipboard in detail.