Будем считать, что вы уже ознакомились с первой частью, где мы поговорили о применяемых технологиях в данном цикле уроков и настроили «среду» для дальнейшей разработки.

План

Как по мне, то материал проще усваивать небольшими порциями. Поэтому мне нужно указать некое содержание:

Предисловие

Хочу вам напомнить, что в этой статье идёт речь про построение node-webkit приложения. Поэтому не стоит искать здесь вёрстки самого приложения. Готовый NodePad, который мы так удачно разрабатываем с вами с первой части статьи можно найти на GitHub.

Советую, для лучшего понимания происходящего, держать открытой вкладку с репозиторием и отслеживать происходящее там, параллельно с ходом статьи.

Оживление интерфейса

Если вы помните первую статью, то краем глаза видели, как будет выглядеть интерфейс нашего блокнота:

NodePad - Оживление интерфейса

К сожалению, толку от такого интерфейса практически нет, разве что глаз радовать. Красиво, но бесполезно — нужно оживлять!

Для общения с DOM мне удобнее работать с jQuery (можно и Zepto), поэтому подключим его стандартным способом в app/views/app.html:

<script src="../scripts/vendor/jquery.min.js"></script>

Разумеется, есть целый веер библиотек (Angular, Backbone, React и Ember), которые созданы специально для разработки одностраничных веб-приложений. Но, в силу того, что я ещё не дорос до их использования, в этой статье мы не увидим ни одного из представителей этой элиты. Возможно, позднее я всё таки постигну кунг-фу Angular и смогу поведать об этом своему читателю. Но, как это всегда бывает, ожидание этого момента может затянуться. Так уж устроена моя лень :)

Точка входа

Точкой преткновения всех наших модулей будет app.js. Создайте его в директории scripts и черкните пару строк своим пером:

//
// Library
//
var gui = require('nw.gui');

//
// Global
//
global.gui          = gui;
global.mainWindow   = gui.Window.get();
global.jQuery       = jQuery;

//
// Requires
//
require('../scripts/windowEvents');
require('../scripts/editor');

Начнём с конца. В силу того, что все наши .js файлы, в какой-то мере являются модулями, то и подключать их нужно не через типичный <script>, а через require().

Внимание

Если вы не понимаете, о чем здесь идёт речь, то вам следует посетить вот эту страницу, на которой можно ознакомиться с модулями Node.js. Спасибо за это Илье.

Следующей нашей остановкой будет секция Library. В этой части файла мы подключаем библиотеку node-webkit, которая даёт нам API для создания собственных элементов управления пользовательским интерфейсом. Подробнее про это можно узнать на специальной странице Wiki.

И наконец, в блоке Global мы делаем некоторые нужные нам переменные глобальными. В итоге, переменная mainWindow будет нести в себе обёртку объектов DOM текущего окна.

Внимание

Подробнее про global можно узнать, посмотрев видео на канале Sorax. Для удобства я уже выставил временную метку на нужный момент. Если же что-то пойдёт не так, то домотайте ползунок машины времени YouTube до 7m10s.

Короче говоря, не забивайте этим голову. Просто примите к сведению, что любая ваша программа на node-webkit должна содержать в себе как минимум две строки для дальнейшей разработки:

// Load native UI library
var gui = require('nw.gui'); //or global.window.nwDispatcher.requireNwGui()

// Get the current window
var win = gui.Window.get();

Кнопки управления окном

Фух, ну его, эти пустые разговоры. Мне уже хочется что-нибудь покодить :)

Первым делом нужно дать возможность пользователю закрывать NodePad. А как ещё? Открыл — Закрыл. Для этого создаём файл windowEvents.js и делаем ранее объявленные глобальные переменные чуточку ближе к нам:

var mainWindow = global.mainWindow,
    gui        = global.gui,
    $          = global.jQuery;

Конечно, можно использовать их и в чистом виде, то есть полностью прописывая объект.переменная, но мы же ленивый народ.

А теперь вешаем обработчик на кнопку закрытия приложения:

$('#window-close').on("click", function() {
  mainWindow.close();
});

И кнопку переключения полноэкранного режима (FullScreen):

$('#window-resize').on("click", function() {
  if ($( this ).hasClass('maximize')) {
    // Переключаем полноэкранный режим
    mainWindow.toggleFullscreen();
    // Убираем класс-метку
    $( this ).toggleClass('maximize');
    // Переключаем иконку
    $( this ).children('i').toggleClass('icon-toolbar-maximize icon-toolbar-minimize');
  } else {
    // Переключаем полноэкранный режим
    mainWindow.toggleFullscreen();
    // Ставим класс-метку
    $( this ).toggleClass('maximize');
  };
});

Ну, и наконец, на кнопку сворачивания окна:

$('#window-restore').on("click", function() {
  mainWindow.minimize();
});

Внимание

Оставлю здесь ссылку, перейдя по которой можно будет ознакомиться со всеми методами объекта window.

На этом этапе мы можем покликать на три кнопки в верхнем правом углу. Порадоваться тому, что наше окно можно закрыть, свернуть, развернуть на полный экран и даже вернуть его обратно. На этом, пока что, наши возможности взаимодействия с интерфейсом заканчиваются.

Перемещение окна

Если вы хотя бы раз пользовались компьютером, то, наверное, уже успели заметить, что окошки программ можно перемещать. Но, так как в первой части статьи мы отключили системную обёрту окна (Window Frame), перемещать у нас его не получится. Согласитесь, это досадное недоразумение нужно исправить.

Для этого у нас есть свойство -webkit-app-region: drag. Мы могли бы его повесить на body, но тогда пользователь не смог бы производить никакие действия с окном приложения, кроме как перемещать его.

И решением нашей проблемы будет гениальная мысль использовать, как и в любой ОС, верхнюю панель (Toolbar). Активной для перемещения областью, обычно, выступает средняя, где нет никаких кнопок, иконок и прочего мусора. Поэтому я создам класс, который будет находиться между областью иконки приложения и кнопок управления окном:

<div class="toolbar" id="window-toolbar">
	<div class="toolbar-brand">...</div>
	<div class="toolbar-drag"></div>
	<div class="toolbar-actions">...</div>
</div>

При этом, нужно не забыть задать стили для этого элемента:

.toolbar-drag {
  -webkit-app-region: drag;
  content: " ";
  flex: 1;
}

Вот теперь то наше окно можно мотать во все стороны и при этом использовать «привязки» Windows.

Контекстное меню

К сожалению, клавиатурные сокращения известны не всем, и с этим нужно что-то делать. До этого момента, пользователь, который не знает комбинаций клавиш, ничего не сможет копировать. Его не удовлетворит наше приложение, а мы потеряем клиента.

В node-webkit предусмотрено API меню, которое поможет нам исправить эту оплошность и сохранить необразованного клиента :)

Сначала создадим модульmenu.js в директории scripts и дописать в файл app.js новый require() в секцию Requires.

...
require('../scripts/editor');
var menu = require('../scripts/menu');
...

И вызов нашей будущей функции инициализации контекстного меню:

//
// Initialization
//
menu.initContextMenu();

Используем событие contextmenu и его обработчик для элемента текстового поля. Производиться действия будут в файле editor.js:

// [0] здесь используется для того, чтобы избавиться от обёртки jQuery.
// Без этого получить координаты x и y не получится.
$('#editor-input')[0].addEventListener("contextmenu", function(ev) {
  ev.preventDefault();
 // Отображаем контекстное меню после клика рядом с курсором
  contextMenu.popup(ev.x, ev.y);
});

Отлично, теперь перейдём к файлу menu.js и займемся формированием меню в виде функции initContextMenu().

Объявим необходимые нам переменные:

var mainWindow = global.mainWindow,
    gui        = global.gui,
    $          = global.jQuery;

Теперь перед нами стоит задача создания контекстного меню. Для этого нам нужно (читаем комментарии к коду):

function initContextMenu() {
  // Создать пустое меню, используя библиотеку nw.gui
  contextMenu = new gui.Menu();
  // Получить объект document
  var mainWindowDocument = mainWindow.window.document;
  
  // Добавить в меню новый пункт
  contextMenu.append(new gui.MenuItem({
    label: 'Copy',
    click: function() {
      // Копировать выделенный текст
      mainWindowDocument.execCommand('copy');
    }
  }));

  contextMenu.append(new gui.MenuItem({
    label: 'Cut',
    click: function() {
      // Вырезать выделенный текст
      mainWindowDocument.execCommand('cut');
    }
  }));

  contextMenu.append(new gui.MenuItem({
    label: 'Paste',
    click: function() {
      // Вставка текста из буфера обмена
      mainWindowDocument.execCommand('paste');
    }
  }));

  contextMenu.append(new gui.MenuItem({
    type: 'separator'
  }));

  contextMenu.append(new gui.MenuItem({
    label: 'Select All',
    click: function() {
      // Выделение всего текста в блоке
      mainWindowDocument.execCommand('selectAll');
    }
  }));
}

// Отдать функцию вызывающему её модулю
exports.initContextMenu = initContextMenu;

Выглядеть это будет, например, так:

NodePad - Контекстное меню

Чудненько, а теперь разберёмся в этом поподробнее:

  • contextMenu.append — Добавление элемента типа MenuItem в конец списка.
  • new gui.MenuItem — Создание нового пункта меню. В параметрах можно указывать:
  • type — Тип пункта (separator, checkbox или normal). По умолчанию normal. Тип может быть установлен только при создании пункта меню и в дальнейшем его изменить нельзя.
  • label — Текст.
  • icon — Иконка.
  • tooltip — Подсказка, появляющаяся при наведении мышки на этот пункт меню.
  • checked — Выделение пункта. Используется для указания того, что опция выбрана.
  • enabled — Отключение и включение пункта меню.
  • submenu — Подменю пункта.
  • click — Обработчик клика по пункту.
  • key — Одиночный символ клавиатурного сокращения.
  • modifiers — Строка, указывающая модификатор для быстрого доступа к меню. Например, "cmd-shift". Значения: cmd, shift, ctrl или alt.
  • execCommand() —Метод объекта document, который выполняет одну из предопределённых операций над документом.

Мне кажется, что нужно слегка подробнее коснуться метода execCommand().

Как мы уже поняли, этот метод даёт нам возможность пообщаться с документом и, в частности, с операцией выделения. Но это лишь верхушка айсберга.

Параметры:

Имя комманды, указывающее на то, что нужно совершить с выделением. Огромный список находится вот тут или тут и, на крайний случай, даже тут.

Пользовательский интерфейс - необязательно логическое значение (true или false), определяющее, нужно ли показать пользовательский интерфейс по умолчанию.

Параметр команды - Необязательный параметр команды. Некоторым командам (например insertimage) также требуется значение аргумента (url картинки).

Перейдите по ссылке указанной в пункте Имя команды и вы найдёте огромное количество команд для управления вашим содержимым.

Внимание

Подробнее про API меню можно узнать на Wiki странице проекта.

Нативное меню (OS X)

Если собрать наше приложения для платформы от Apple, то мы увидим, что так называемый «MenuBar» пустует:

NodePad - Нативное меню OS X по умолчанию

Мне бы такое меню не понравилось в моём приложении, поэтому переходим к файлу menu.js и создаём новую функцию:

//
// Menubar
//
function initBarMenu() {
  // Создаём пустое меню типа menubar
  var nativeMenuBar = new gui.Menu({ type: "menubar" });

  // Если приложение запущено на OS X платформе, то формируем бар
  if (process.platform === "darwin") {
    // Управление пунктами «Заголовок», «Правка», «Окно»
    nativeMenuBar.createMacBuiltin("NodePad", {
	  hideEdit: false,
	  hideWindow: false
	});
  }
  mainWindow.menu = nativeMenuBar;
}

Используя hideEdit и hideWindow можно скрывать или показывать пункты меню «Правка» и «Окно» соответственно.

Также существует краткая запись, которая позволяет опустить hideEdit:false и hideWindow:false при записи:

nativeMenuBar.createMacBuiltin("NodePad");

Не забываем добавить:

exports.initBarMenu = initBarMenu;

Инициализируем нативное меню в app.js:

//
// Initialization
//
menu.initBarMenu();
menu.initContextMenu();

Вот, уже другое дело:

NodePad - Нативное меню OS X с пунктами

Внимание

Работа с MenuBar практически ничем не отличается от работы с обычным меню. Раздел документации посвященный этому находится тут.

Markdown

Суть нашего детища — работа с языком разметки Markdown. В прошлый раз мы подключили npm пакет Markdown. Самое время воспользоваться им.

Создаём файл editor.js, и делаем глобальное слегка локальным:

var markdown = require( "markdown" ).markdown,
    $        = global.jQuery;

Отслеживание изменения содержимого текстового поля я предлагаю проводить при помощи jQuery плагина jQuery Text Change Event от небезызвестной Zurb. Подключим плагин в файле app.html:

...
<script src="../scripts/vendor/jquery.min.js"></script>
<script src="../scripts/vendor/jquery.textchange.min.js"></script>
...

В файле editor.js обрабатываем событие textchange, которое всплывает, если каким-либо образом изменяется текст поля:

$('#editor-input').on("textchange", function() {
  // Получаем данные из поля
  var content = $( this ).val();
  // Выводим в окно предпросмотра преобразованный Markdown в HTML
  $('#editor-preview').html(markdown.toHTML( content ));
});

Теперь, при редактировании контента, написанного с использованием языка разметки Markdown, мы сможем в режиме реального времени наблюдать изменения в окне предпросмотра:

NodePad - Markdown разметка

А как же событие keyup()?

Отличительной особенностью события keyup() является то, что вызывается оно на отпускание клавиши клавиатуры. С одной лишь клавиатурой это бы отлично работало, но у людей также есть мышки, а, помимо них, есть меню редактирования «Правка». Именно поэтому здесь используется плагин.

Почему используется on() вместо bind(), как в документации?

Начиная с версии jQuery 1.7 предполагается повсеместное использование метода on(). Все остальные методы (bind, live, delegate) будут потихоньку переходить в статус «нежелательно к использованию» и впоследствии удаляться. На данный момент, метод live() уже удалён, а delegate() и bind() на своих страницах jQuery API вещают нам, что пора смотреть в сторону on(). Единый метод был введен для того, чтобы не возникали вопросы какой метод использовать.

Локальное хранилище

К сожалению, жизнь пролетает практически мгновенно перед глазами, когда пишешь какой-нибудь важный документ и сроки горят, а тут окно нашего блокнота закрывается, свет мигает или метеориты падают в электроподстанции вашего города — но текст вашей работы нужно сохранять, даже если завтра конец света, и он вовсе вам больше не понадобится, в виду некоторых особенностей нового постапокалиптического общества, представители которого, возможно будут забивать гвозди вашим HDD.

Оставим шутки в стороне и приступим к воплощению идеи в код. Для этого мы будем использовать Local Storage (локальное хранилище), о котором не так давно я писал статью в блоге. Самое время начать пользоваться им.

В app.js необходимо объявить глобально:

global.localStorage = window.localStorage;

Обновим глобальные переменные для модуля editor.js:

var ls       = global.localStorage,
    markdown = require('markdown').markdown,
    $        = global.jQuery;

Повесим обработчик уже знакомого нам события textchange:

$('#editor-input').on("textchange", function() {
  // Получаем контент из поля ввода
  var content = $( this ).val();
  // Записываем его в хранилище
  ls.setItem("content", content);
});

Отлично, теперь у нас есть копия того, что вводится в окно. Однако, этого не достаточно, ведь её нужно выводить при загрузке блокнота, иначе какой смысл в сохранении этих данных. В этот ответственный момент к нам приходит на помощь событие загрузки окна loaded. Поэтому со спокойной душой вешаем на него обработчик и выводим данные при запуске программы:

mainWindow.on("loaded", function() {
  // Получаем данные из хранилища
  var content = ls.getItem('content');

  // Если данные есть, то выводим их
  if (content) {
    $('#editor-input').val( content );
  }

  // Вызываем событие изменения данных в поле
  $('#editor-input').trigger('textchange');
});

Работа с файлами и уведомления

Чего же нам не хватает для полного счастья? — Наш блокнот хочет общаться с окружающим миром. И делать он это может посредством файлов.

Для того, чтобы работать с файлом, нам нужно использовать файловый диалог, получить от него путь до файла и открыть его или сохранить, в зависимости от сценария работы.

Я же предлагаю использовать несколько другой путь:

  • Заглянуть в менеджер пакетов npm.
  • Обрадоваться тому, что есть пакет node-webkit-fdialogs.

Внимание

По старой доброй традиции, вновь предлагаю перейти по ссылке и узнать как работать с файлами в node.

Добавляем в зависимости NodePad этот пакет, перейдя в директорию nw-app и выполнив команду:

npm i node-webkit-fdialogs --save

Я также предлагаю закрепить тему работы с npm при помощи создания нативных всплывающих уведомлений о том, что файл успешно сохранён. Подключаем ещё один пакет:

npm i node-notifier --save

Кстати, сейчас секция зависимостей приложения в package.json выглядит вот так:

"dependencies": {
  "markdown": "^0.5.0",
  "node-notifier": "^4.0.2",
  "node-webkit-fdialogs": "^0.2.7"
}

После этого в модуле windowEvents.js обновляем переменные в шапке:

var mainWindow = global.mainWindow,
    gui        = global.gui,
    path       = require('path'),
    fdialogs   = require('node-webkit-fdialogs'),
    notifier   = require('node-notifier'),
    $          = global.jQuery;

Теперь нам нужно научить NodePad открывать файлы и сохранять их. Для этого создаём обработчик клика на соответствующий пункт меню (Open или Save As):

//
// Open
//
// ----------------------------------------
// #app-open          -> ID кнопки диалога сохранения.
// #app-function-menu -> ID меню.
// #editor-input      -> ID поля, содержащее введённый текст (textarea).
// ----------------------------------------
$('#app-open').on("click", function() {
  $('#app-function-menu').toggle();
  // Формируем диалоговое окно открытия
  var openDialog = new fdialogs.FDialog({
    // Тип диалога -> Открытие файла
    type: 'open',
    // Доступные расширения -> .md
    accept: ['.md']
  });

  // Показываем диалог открытия файлов
  openDialog.readFile(function(err, data, path) {
    // Записываем содержимое файла в окно редактирования
    // Вызываем событие изменения текста для его трансляции из Markdwon в HTML
    $('#editor-input').val( data ).trigger('textchange');
  });
});

Попутно, там же, создаём обработчик сохранения:

//
// Save
//
// ----------------------------------------
// #app-save          -> ID кнопки диалога сохранения.
// #app-function-menu -> ID меню.
// #editor-input      -> ID поля, содержащее введённый текст (textarea).
// ----------------------------------------
// notifier           -> npm node-notifier
// ----------------------------------------
$('#app-save').on("click", function() {
  $('#app-function-menu').toggle();
  // Забираем текст из окна редактора
  var content = $('#editor-input').val();
  // Создаём буфер
  var contentBuffer = new Buffer(content, 'utf-8');
  // Формируем диалоговое окно сохранения
  var saveDialog = new fdialogs.FDialog({
    // Тип диалога -> Сохранение файла
    type: 'save',
    // Доступные расширения -> .md
    accept: ['.md']
  });

  // Показываем диалог сохранения файла
  saveDialog.saveFile(contentBuffer, 'utf-8', function(err, filepath) {
    // Формируем всплывающее окно
    notifier.notify({
      title: 'NodePad',
      // Формируем строку, в которой будет написано: Файл MyLittlePony.md успешно сохранён!
      message: 'File ' + path.basename( filepath ) + ' successfully saved!',
      icon: path.join(__dirname, '../images/brand.png')
    });
  });
});

Подробнее про создание нативных всплывающих уведомлений можно почитать тут.

Из всей этой части статьи нужно уяснить лишь то, что работать с пакетами из npm вам придётся практически всегда, конечно, если только вы не велосипедист по жизни :)

Внимание

Node-webkit до версии 0.11.2 плохо работает с отличными от английского языка файлами. Поэтому, если ваш файл сохраняется с неверной кодировкой или не полностью, то проверьте версию node-webkit.

Закрытие дыр

Пожалуй, это самая неинтересная часть в статье. У нас уже имеется всё, что требуется настоящему приложению. Наш NodePad теперь имеет право называться простым блокнотом, и им даже можно пользоваться.

Но, как это иногда бывает, появились некоторое проблемы:

  • Если добавить в текст ссылку, то по ней можно перейти и назад пути не будет.
  • Если быстро изменять размер окна, то появляется артефакт в виде черной подложки.
  • Если на OS X открыть приложение во весь экран, то сверху будет полоса, на которой видно содержимое <title> в вёрстке.

Решить на данный момент полностью можно только первую проблему, частично третью и никак вторую.

Для решения первой проблемы в класс окна предпросмотра добавим следующее свойство:

pointer-events: none;

Советую ознакомиться c описанием этого свойства при помощи статьи, автор которой Павел Радьков.

Со второй проблемой всё намного сложнее. Черная подложка окна — это то ли баг Chromium, то ли баг node-webkit — мне, лично, не понятно, и как бороться с этим я найти информации не смог. Надеюсь, что скоро всё исправят, хоть, это и не мешает работе приложения. Кстати, этим недугом страдает и сам Chrome. Попутно с ним подобные проблемы были замечены и у Opera, немного у Firefox и даже самую малую долю у IE (вверху бара).

А вот с третьей проблемой разобраться совсем легко — просто удалите содержимое тега <title> и в полосе будет красоваться название приложения, а не содержимое тега заголовка страницы.

Вывод ко второй части

В такой ответственный момент, когда статья уже сводится к своему логическому завершению, хотелось бы провести маленький дедлайн.

Наш блокнот подрос, окреп и теперь он умеет:

  • Взаимодействовать с пользователем (Полный экран, сворачивание, закрытие, перемещение окна).
  • Редактирование текста, с использованием контекстного меню.
  • Вводимый текст в режиме реального времени транслируется из Markdown в HTML и отображается в окне просмотра.
  • Вводимый текст в режиме реального времени сохраняется в локальное хранилище.
  • При закрытии блокнота, редактируемый в нём контент не исчезает, а хранится в веб-хранилище. При открытии блокнота сохранённый контент автоматически подгружается в окно редактирования.
  • Общаться с другими редакторами посредством файлов.
  • Заботится о своих пользователях и не даёт им случайно отвлекаться от работы.

Иии...

И снова мне хочется сказать, что это не неожиданный финал статьи, а всего лишь её уже вторая часть. Третья часть выйдет в обозримом будущем и не заставит себя долго ждать.