(Блин, это случайная опечатка: я имел в виду, «etherquill», от etherpad и quill, но написалось either – «и тот», а не «эфир». Теперь не знаю, исправлять во всех местах в коде, или сделать именно это название официальным…) Итак! Вы не поверите, но всё это время я делал пад )) Правда. Это было увлекательно. Сейчас я буду рассказывать историю, в которой будет много джаваскрипта (и шейсят пять тыщ символов…) Так, для начала я пошёл на гитхаб эзерпада, чтобы посмотреть, есть ли у них какие-нибудь подвижки. Никаких! Ядро пада осталось неизменным с 2014-го года. Я не поверил своим глазам, и выкачал текущую ветку, чтобы самому запустить и проверить. (Хотя, какие-то там коммиты вроде и датированы недавними месяцами, но это develop, и фактических нововведений как будто бы нет. Stable-ветки все старые, новых веток так вообще нет). Первый же баг воспроизводился тривиально прямо из коробки – что значит, что они не потрудились исправить даже его. Суть в том, что если выбелить чужой текст, а потом отменить действие – то сервер выкидывает клиента из пада, выдавая в свою консоль ошибку, мол, «попытка выдать свои правки под чужим именем». Там стоит проверка, что если кто-то пытается указать атрибут авторства на вставляемом или изменяемом тексте – то это авторство должно быть его личным. А «отмена» работает в лоб – возвращает пад в то состояние, которое было перед правкой. И на сервер уходит физически перекраска текста, а не запрос типа «верни то, что было тут до моего исправления». Тогда, когда я настроил вам систему etherpad-lite + mysql – я обошёл эту ошибку просто удалением проверки требования собственного авторства. Только так функция отмены могла нормально работать. Но etherpad – это не только сам пад, это ещё и плагины к нему. Которые расширяют функционал и добавляют новые виды форматирования. А знаете, какая дата обновления у большинства из них!? Да они при всём желании не будут работать нормально, даже если бы ядро etherpad обновлялось. Это вообще какая-то убитая система, там для каждого плагина сколько кривого кода прикостылить требовалось, и отсутствовал всякий контроль над его взаимной интеграцией в систему. Вообще, любой пад – это три большие составляющие: • текстовый редактор: поддержка браузеров, перехват клавиш редактирования, изменение позиции курсора, форматирование, отмена, копирование и вставка; • сервер и база данных: подключение клиентов, регистрация (имя и цвет), создание падов, чат и обмен статусными сообщениями, список подключённых пользователей; • алгоритм операционных преобразований: история правок, коллаборация в реальном времени – возможность одновременно редактировать единый документ. Из всего этого мне наиболее интересно последнее, и наименее интересно первое. То есть, я не хочу разбираться, почему в одном браузере, скажем, текст вставляется нормально, а в другом – курсор прыгает на следующую строку. Но зато – написать реальную математическую модель, которая гарантирует синхронизацию правок и даёт максимальное удобство контроля над процессом – вот это было бы круто! А такая модель уже есть, и называется easysync: https://github.com/ether/etherpad-lite/tree/develop/doc/easysync (смотрите .pdf файлы там) В то время я подробно изучил, что такое «changeset», как они записываются и что обозначают. Разумеется, сейчас я обновил знания, а у них с тех пор там ничего даже не поменялось. В целом: есть «документ», это набор «правок». Каждая правка содержит: • длину документа, к которому была применена; • длину результирующего документа; • массив «операций», каждая из которых может быть: – удалением скольких-то символов в строке или сколько-то строчек целиком; – применением неких атрибутов к символам или строкам; – вставкой определённого количества символов или строчек; • список вставляемых символов, которые идут после перечисления операций, но логически являются частью всех операций вставок (так называемый «пул данных», все вставки берут подстроки из него); • список изменяемых атрибутов, некоторые из которых могут быть флагами (жирность), а некоторые – строками/значениям (цвет), но все атрибуты кодируются числами; • массив сопоставлений чисел атрибутов самим значениям атрибутов, однако на самом деле он не входит в правку (чейнджсет), а хранится как свойство текущего документа и просчитывается отдельно. У этой системы есть куча неудобств, но etherpad-lite изначально был прямым javascript-портом кода Etherpad, который Google писали на Java и открыли (кажется, в 2012-ом или чуть раньше). Во-первых, неудобно то, что весь changeset – это дикая строка, с которой невозможно работать напрямую (в ней даже все цифры в 36-ричной системе счисления, это что-то среднее между HEX и BASE64). Может «хранить» и было нормально, но для любых самых простейших действий – нужно обращаться к «ассемблерам операций», которые парсят строчку и посимвольно выстраивают списки действий, чтобы потом что-то с ними сделать. Во-вторых, атрибуты записаны донельзя отвратительно. Из-за того, что у отдельно взятого чейнджсета есть только _коды_ атрибутов (из «пула» атрибутов, который нужно приложить дополнительно), невозможно было работать с правками как с отдельными сущностями. Они всегда шли в паре с пулом атрибутов, например в документе символ имеет атрибут «1», что значит «жирный», а новая правка добавляет новый символ с атрибутом «1», что по её мнению, значит «курсивный». Тогда для «объединения» документа и правки (то есть, применения правки и получения нового документа) движок должен был найти «курсивный» в исходном пуле атрибутов и подменить в правке его код, либо – добавить его в свой пул с новым номеров. Хотя, для внешнего вызова всё это blackbox, и не обязательно было знать, что такое пул, и что в нём хранится – а просто передавать его в функции, которые его просят. Но всё же. В-третьих, все правки зачем-то разделяют операцию «внутри строки» и операцию «с несколькими строками». Я думал, это для случая, когда двое пользователей редактируют разные строки – чтобы их тексты ни в коем случае не перемешались. То есть, движок каждый раз «считает», на какой строке была сделана правка, и, скажем, при вставке новой строки перед ней – понимает, что теперь на самом деле редактируется строка со «следующим» номером. Кстати, движок вылетал, если правка ожидает новую строку, а там нету. И мне было непонятно что делать – винить кривой easysync, или всё же я в этот момент не ту правку не к тому документу применяю… В-четвёртых, в их матчасти говорилось, что «merge» двух правок – это такой документ, который будет одинаков независимо от порядка применения этих правок. Но это невозможно: допустим, в пустой документ кто-то написал «1», а ещё кто-то – «2». Сколько теперь там, «12» или «21»? (На самом деле, этот вопрос скорее всего решён где-то в коде etherpad, просто я не понял, где). В эту проблему я упёрся, когда пытался сделать «оффлайн-пад»: в какой-то момент применение одной правки поверх другой работало, а в обратную сторону – нет; обошёл костылём вместо применения правки полной заменой всего текста документа на заведомо корректный, для оффлайн-случая это как раз работает. А ещё, я однажды упёрся в странность обработки пула атрибутов, например если в исходном документе текст был жирным, а в очередной правке – уже нет, то это что означает, что жирность надо убрать, или что эта правка просто не меняет её? Оказывается, это определяется настройкой функции, которая используется для применения композиции. Короче, сложно всё. Зная easysync и в общих чертах – устройство «сервера», остаётся только один вопрос: как же etherpad создаёт сам пад-редактор в браузере? Его редактор – это некий «Ace», и по словам самих разработчиков etherpad – очень древней версии (2009): https://github.com/ether/etherpad-lite/issues/1763#issuecomment-76454187 (Вообще, интересное обсуждения развития и будущего всей системы). Редактор в паде – кривой. С одной стороны, его кривизну пытается исправить сам etherpad, но с другой – он же и использует напрямую внутренности Ace. То есть – они связаны, сильно-сильно связаны! Особенно по применению атрибутов – «как именно текст становится (например) жирным?» – ответ на этот вопрос лежит на границе etherpad и Ace. Но знаете что? Ace – развивается, и на текущий момент он стал просто офигительным редактором, просто взгляните на него! https://ace.c9.io/build/kitchen-sink.html Он умеет ВСЁ: подсвечивать синтаксис, нумеровать и свёртывать строки, автодополнять, а ещё там можно зажать Alt и получить квадратное выделение, или Ctrl и создать кучу «курсоров», которые будут адекватно подчиняться стрелочкам на клавиатуре… Не умеет он только одного: форматировать текст. То есть – это «блокнот», это не «ворд»: там нет жирности текста, выравнивания абзацев, списков, рисунков, ссылок, цветов, авторства. Вернее, можно лишь сделать, чтобы например «все цифры были красные и жирные», но это не форматирование, это оформление. Потому что это редактор _кода_, а не WYSIWYG-редактор. Видать, раньше он был другим, раз etherpad взяли его. Или – они его «очень хакнули». Я попробовал хакнуть – не получилось: я пытался добавить невидимые символы (display:none), которые бы триггерили изменение форматирования того что между ними (ну как кавычки – делают текст «строкой», которая подсвечивается по-другому), а сами при этом не мешали бы работе. Но увы, из-за этого в редакторе сбивалось абсолютно всё, начиная с позиции курсора – он рисовался уже не там, где фактически появляются печатаемые буквы. Гиблое дело. Я погуглил, чтобы найти какой-нибудь другой wysiwyg-редактор, к которому я смог бы прикрутить свою собственную реализацию а-ля easysync. Первым вышел вот этот: https://prosemirror.net/ Но мне он показался каким-то кривым, и с очень непонятным API. Я отбросил его. Вторым был вот этот: https://quilljs.com/ И он хорош! Во-первых, он на вид не был глючным, и поддерживал всё, что нам нужно. А во-вторых, у него внутри уже есть своя собственная реализация библиотеки операционных преобразований: https://quilljs.com/guides/designing-the-delta-format/ – она называется «Delta» (вместо «changeset»), и внутреннее устройство оказалось гораздо проще и логичнее: • дельта – это javascript-объект, который можно сериализовать в JSON. Скорость парсинга – максимально возможная. • хранится только массив операций, почему-то не запоминается даже длина исходного документа. Ну и правильно, она не особо нужна, если всё в порядке и корректно применяется… • операции бывают также трёх типов, но с одной оговоркой: – удаление скольких-то символов (число, прямо в десятичной системе); – форматирование (или простой пропуск) скольких-то символов, тоже число. Плюс – массив атрибутов; – вставка строки. Её длина – это и есть, сколько символов вставили. Также, есть массив атрибутов; – вставка, но «объекта» («embed») – это что-то типа картинки или ещё чего-то такого, что должно вести себя по типу смайлика в тексте. Его можно выделить, можно скопировать, можно изменить его свойства (URL, например), но нельзя зайти курсором «внутрь» него. Его длина – единица, но он по-прежнему считается вставкой; • дельта не различает «переносы строк» как-то по-особенному. Перенос строки – это такой же обычный символ, как и все остальные, и его можно удалять, пропускать или вставлять, не задумываясь об этом. • массив атрибутов, используемый при пропуске-форматировании и вставке – на самом деле, это javascript-объект, ключи которого – это названия атрибутов («bold», «header», «list»), а значения – то, чему нужно приравнять этот атрибут. Например, {bold:true} сделает текст жирным, но не тронет другие атрибуты, а {header:2} – завернёт строку в

. Особым образом обрабатывается значение null – оно обозначает снятие соответствующего атрибута (например, «color:null» убирает цвет текста). • благодаря тому, что все атрибуты хранятся в самой дельте – с дельтами можно работать напрямую, без всяких пулов. Однако, сама по себе дельта – не знает, что такое «bold» или «image», для неё это просто ключи объекта, которые имеют какие-то значения. Физический смысл им придаёт та часть системы Quill, которая будет отображать документы на экране. (Кстати, embed-объекты могут, вроде бы, иметь несколько ключей внутри себя, как я понял. А это значит, что им уже не нужны настоящие «атрибуты»: например, ссылка как ссылка задавалась просто наличием атрибута link, а вот у изображения – адрес можно хранить в самом объекте. Но с этим надо разобраться, а то в документе выше, они вроде написали, что width картинки пишется в атрибуты, а не в сам embed). Вы поняли, да? Мне НЕ нужно писать свою библиотеку операционных преобразований!.. (а я так хотел) К тому же, они реально сделали из дельты библиотеку, и опубликовали отдельно: https://github.com/quilljs/delta Я остановил свой выбор на Quill, хотя есть и третий кандидат: https://firepad.io/examples/ Выглядит ничёшно. Как он работает внутри – я пока не разбирался (если знаете или сможете попробовать – пишите), но я вижу, что его библиотека операционных преобразований – практически такая же, как у дельты: https://github.com/FirebaseExtended/firepad/blob/master/lib/text-operation.js Прежде чем браться за Quill, я стал разбираться, каким образом он придаёт тексту нужные атрибуты. И за это у них отвечает система Parchment (тоже выложенная отдельной библиотекой). Как я понял – это иерархическая структура, которая позволяет представить «документ» как набор элементов. В целом они делятся на три типа: • блочные: форматируют всю строку целиком; • строчные: форматируют несколько символов в одном блоке; • объектные (embed): создают штуку, которая становится как «буква» в тексте. Parchment создаёт в памяти виртуальную структуру документа, но при этом умеет «вырисовывать» её в HTML на странице. Ему задаются все необходимые варианты форматирования (они регистрируются принудительно; Quill по умолчанию регистрирует основные свойства типа жирности, цвета, шрифта, размера и так далее), объясняется, как переводить их в HTML и обратно (а именно, делать ли новые теги типа , писать ли через CSS-классы, типа
, или прямо в атрибут стиля, мол, ). Эта структура отражается в HTML-документе, а воздействия на него – переносятся обратно в Parchment. Фишка в том, что это даёт единообразность представления документов: ведь в html можно накидать «чего угодно», и это «как-то» будет отображаться на экране, а в Parchment будут допустимы только вполне конкретные теги/классы/свойства, а всё остальное будет отброшено. Поэтому даже вставка в браузер из Word не должна сломать пад, потому что Parchment возьмёт только те теги, с которыми знает, что делать. А на выходе мы получаем отличную дельту, «атрибуты» которой автоматически соответствуют исходному форматированию документа! Например, если поставить курсор в жирную строку, и написать что-то – Quill сгенерирует дельту, в которой уже будет стоять правильный атрибут жирности текста. Впрочем, это сделает не сам Parchment, а часть Quill, которая объединяет его и дельту в цельный механизм. (Там ещё и diff на дельтах как-то завязан, я не вчитывался) Отдельно надо сказать, как Quill обрабатывает блочные форматы, например – «заголовки» (H1, H2, …), ведь если выделить часть строки, то заголовок должен примениться сразу ко всей строке (или не применится вовсе). Вообще-то, даже выделять не надо: поставить курсор на строку, и нажать «по правому краю» – и весь абзац должен выровняться по правому краю. Так вот, он это делает – хитро: такая правка работает не с любым текстом, а технически применяется исключительно к символу переноса строки, к «\n». Вот где они обрабатываются по-особенному, в блочных форматах. В пустом документе – всё равно есть один перенос строки, и вообще он всегда идёт последним символом, и не удаляется. Так… Теперь, перед тем, как я расскажу, что же я в итоге накодил, я распишу немного своих идей и «фишек для пада», которые хорошо было бы иметь. Это не значит, что они обязательно будут, или что их вообще возможно сделать, но я хотя бы хочу просто перечислить. (Словом «клиент» я обычно называю клиентскую часть кода, а не самого пользователя-человека). 1) Оффлайн функционал. Это значит, что вся история пада выкачивается и храниться локально. Даже при отключении от сервера, пользователь всё ещё может редактировать содержимое или смотреть прошлые ревизии. При подключении к интернету, можно будет нажать что-то типа «синхронизировать», и клиент выкачает новые правки, объединит их с текущим документом, и отправит на сервер (быть может, сначала попросив подтверждения, показав текущий обновлённый целевой вид), а далее – либо перейдёт в онлайн-режим, либо позволит вот так время от времени синхронизироваться и «сохранять» работу на сервере. Чтобы можно было «открыть» пад без интернета, можно будет точно также предусмотреть некий скрипт, который придётся вставить в консоль, чтобы открылся интерфейс с падом. Все остальные скрипты, и всю историю – нужно будет сохранять в IndexedDB (это такая внутрибраузерная база данных, очень хорошая, кстати), и локально доставать оттуда. 2) Просмотр истории в реальном времени. То есть, будет кнопка «пауза», переводящая пад в read-only, и тогда можно включить перемотку назад и вперёд в истории. Ну, разница в том, что это будет происходить в том же самом окне и интерфейсе, без выхода в отдельный фрейм или окно. Технически, редактирование даже не требуется запрещать, ведь клиент может дать правку в любую точку истории, а сервер переведёт её в текущее состояние автоматически (ну, вообще говоря он обязан требовать от клиента только относительно свежую базовую ревизию, но это всё настраивается). Правда, если это онлайн-пад, то правка в прошлом не имеет логического смысла, а вот скопировать и потом вставить – очень даже полезно. 3) Ночной режим. Ну, тема, вернее – изменение фонового цвета, и цвета текста. То есть, был чёрный текст на белом фоне – будет белый на чёрном. При этом, авторские цвета (которые относительно светлые, и применяются к фону) – станут действовать не на фон, а на цвет текста. Кстати, наша команда по Спайро единогласно пожелала, чтобы авторство задавалось именно цветом шрифта, а не фона. Фоном – будет «выделение важного» (ну как в Word). Так вот, в этом ночном режиме – как раз так и было бы, ведь если обычно фон показывает авторство, то цвет текста – свободен, и его можно использовать для выделения (и это будет «тёмный» оттенок в палитре). А при ночной инверсии – он станет фоновым цветом – тёмным, потому что общий фон чёрный. Впрочем, чисто за счёт палитры можно добиться обмена ролей автор-цвет, но придётся поработать над соответствием светлого (для фона) и тёмного (для цвета) оттенков одного и того же авторства. 4) Выбеливания не должно быть. Это будет просто такой новый атрибут (не цвет и не авторство), который можно наложить и снять. Также, быть может – будет кнопка для «просмотра без него». Рядом с кнопочкой «скрыть (локально) авторские цвета», ага. Кстати, имея выбеливание – логично создать и «вычёривание», закраску фона цветом текста, чтобы он стал визуально скрыт. Тогда нужна и кнопка «скрыть вычёренный текст», чтобы он не мешал работать (но и не был фактически удалён). Ну это как тогда, когда я делал такую кнопку для сокрытия зачёркнутого текста, только это будет без принесения в жертву самого зачёркивания. 5) Идентификатор авторства – будет не токен, а некая identity, равная самому логину. То есть в правках авторство будет задаваться не абстрактным «user_1», и не «s.ab125fde», а непосредственно юзернеймом типа «aleksusklim». И цвет автора будет задаваться именно ему, причём не в самих правках, а отдельно. Переименоваться будет нельзя, нужно именно логиниться на сайт (со своим паролем), чтобы получить доступ к редактированию. (При этом доступ на чтение, быть может, можно дать и без регистрации/логина, если сделать таковую настройку приватности пада). Логично, что в разных падах один и тот же пользователь сможет взять разные цвета. 6) Выделение текущей фактической правки в режиме просмотра истории – цветом того автора, который её сделал. Например, если кто-то вставил в пад текст, окрашенный в цвета других пользователей (скопировав его откуда-то) – то при просмотре истории, можно было бы включить режим, в котором этот текст был бы целиком окрашен в его собственный цвет. Также, можно визуально показывать, кто выполнил то или иное действие, например – сделал текст жирным. Изменения атрибутов же нельзя показать цветом авторства? В любом случае, в списке пользователей автор текущей правки тоже должен быть выделен. 7) Невозможно запретить «подделывать» авторство других пользователей (потому что мы выяснили, что это ломает отмену и вообще, копирование чужого текста с сохранением цвета). Это значит, что технически (даже если в интерфейсе не будет предусмотренных для этого кнопочек) можно «перехватить» любое чужое авторство, перекрашивать текст, и писать чужим цветом – ну потому что откуда серверу знать, это вы от себя сейчас текст пишите, или по буковкам копируете чужой текст (быть может, взяв его из прошлонедельной ревизии). Однако, сервер всё ещё может следить, кто на самом деле постит те или иные правки – и сохранять это куда-то дополнительно в атрибут «оригинальный автор». И потом, на клиенте можно включить режим просмотра оригинальных авторов, чтобы весь кем-то вставленный разноцветный текст стал значиться его собственным цветом. В любом случае, это немного поможет при модерации, верно же? 8) Нужно разрешить принудительно менять цвета вышедших пользователей. Только надо подумать, как. Вообще, мне не очень нравится, что у вышедших бледнеет цвет. Проблема в том, что он фактически забирает цвета из возможной палитры – и всех возможных цветов становится меньше, либо – они начинают совпадать (ну так и было). Может быть, сделать какой-то «нейтральный» цвет (серый), которым можно, локально и по желанию – покрасить всех вышедших, чтобы их цвета не отвлекали. С другой стороны, палитра ж не резиновая – и на «много» людей её всё равно не хватит, а значит – физически перекрашивать старых было бы неплохо. А нужно ли дать официальную возможность принудительно перекрашивать отдельные куски текста в чужие цвета (менять авторство) – надо ещё подумать, может и нужно. 9) При тыке в цветной текст (а, вообще говоря, _весь_ текст цветной, даже если сейчас он выбелен) – в списке авторов должен как-то подсветиться пользователь-автор. Все вышедшие авторы (то есть пользователи, хоть раз оставившие правку) – тоже должны показываться в другом списке (быть может, свёрнутым по умолчанию). А при щелчке по конкретному автору в любом списке – пад должен визуально подсветить его текст, например – сделав весь остальной чужой цвет выделения вполовину прозрачным/бледным. Потому что рано или поздно цвета палитры закончатся, а людей всё ещё надо хоть как-то различать. 10) Навигацию внутри пада и между падами надо улучшить. Кроме ссылок на строки и между падами (что-то, работающее через якорь # в адресе, но наиболее удобно, автоматически) нужно что-то типа ссылок на подзаголовки или разделы. Может быть, сделать систему #хештегов, работающую в пределах документа. Чтобы можно было написать «#TODO», а потом быстро переходить вверх и вниз по этим пунктам. Это будет типа закладок (которые в отличие от номеров строк, будут оставаться привязанными к своей позиции). Список хештегов можно автоматически вести в каком-то соседнем элементе управления (как чат или список пользователей). Упоминание (mention) пользователя по @-имени тоже можно применить, оно особенно полезно будет в чате. Только надо сообразить эргономичность его использования. 11) ВКЛАДКИ!! Это будет чума. Короче, в паде можно будет «создать вкладку» (с именем или номером). Тогда, при нажатии на неё – текст в паде станет представлять из себя то, что написано в этой вкладке. Это как бы «другой пад», только на самом деле, тот же самый. В нём своя нумерация строк, и он даже «не будет виснуть», когда в соседней вкладке дохрена текста! Несмотря на то, что у них общая история, база, чат и список пользователей. Технически, это будет «другой документ», а у каждой правки появится номер документа (в том же самом паде), к которому она применяется. Во время просмотра истории – вкладки отображаются в том виде, в каком они были в тот время. Разумеется, вкладки можно создавать и удалять, и конечно же – копировать из них текст. Это решит именно ту проблему, которую мы решали «отчёркиванием» половины пада, чтобы получить вторую порядковую нумерацию. Так, по вкладкам можно будет разбивать разные аспекты работы – транскрипт, обсуждаемый перевод, финальную сборку, отдельно перевод песен и просто вкладка для общих мыслей или флуда (заглавная например, где ссылки на видео и так далее). 12) В системе etherpad, один клиент не мог установить два подключения к серверу от имени одного пользователя. Это техническое ограничение, но оно довольно глупое, и связку можно простроить без него. Для этого каждый пад-«клиент» сперва авторизуется на сервере под именем какого-то юзера (детали передачи пароля&co можно определить позднее), и сервер выдаёт ему некий временный ID, по которому отличает его правки от других. Теперь, клиент может писать в пад под тем юзером, под которым он авторизован. Если пользователь захочет открыть тот же пад во втором окне – это будет уже другой клиент (никаких cookie, все данные передаются в POST-запросах джаваскриптом, а сохраняются где-нибудь в localStorage, если не IndexedDB), но он «войдёт» в пад под именем того же самого юзера (если пользователь не потребует иного). И ничего – будет открыто два синхронных окна, в каждом из которых будет отображаться актуальная версия текста, а все правки – будут обозначаться цветом одного пользователя (и он будет в онлайн-списке единожды, хотя зашёл как бы дважды). Сервер не запутается, кто есть кто, потому что внутри эти два окна будут авторизованы как _разные_ клиенты, просто под одним юзером – и, например, второе окно примет правку из первого окна как «чужую», несмотря на то, что в авторстве значится тот же пользователь. Тоже самое и с чатом. А фишка в том, что в разных окнах можно открыть разные части пада, или зайти с разных браузеров (ну фиг знает, может у него в Хроме вставка из буфера обмена не работает, а в Мозилле всё чётко, но пад тормозит…) 13) Развитием предыдущей мысли станет split-view, когда в одном окне отображается один и тот же пад дважды (по вертикали или по горизонтали; технически, это будут разные текстовые поля). Может – разные вкладки одного пада – тогда можно устроить ещё и «синхронный просмотр» (но это сложно, зато не нужно будет писать перевод ПОД оригиналом, их станет возможно размещать в разных вкладках на соответствующих строчках). Я думал о том, насколько свободным должна быть настройка разделения вида – то есть, можно ли создать три фрейма, четыре, десять? – и понял, что наиболее реально сделать просто два, по горизонтали или по вертикали (по желанию). Это локальная временная настройка, серверу даже знать об этом не надо. Зато, клиент сможет отображать открытые ссылки-переходы не в текущем виде, а во втором фрейме (может быть по какому-нибудь особому клику по ссылке, например среднему). 14) Окно отправки сообщения чата – это будет такой «маленький пад», где точно так же можно будет делать форматированный (жирный, например) текст и ссылки. Может у него будет очень минималистичный интерфейс, и гораздо более скудные функции по редактированию (мы же просто единожды отправляем сообщение, а не коллективно модифицируем). Зато упрощается сам подход: используется такой же редактор, как в самом паде, и новые сообщения на сервер приходят в том же формате, с которым он и так уже умеет работать (но где его хранить – это отдельный вопрос; например, нужно ли воссоздавать прошлый вид чата при просмотре истории? Вообще, было бы неплохо). 15) Раз я сказал, что клиент будет запоминать и хранить всю историю локально (и отдельно выкачивать недостающие кусочки), значит – это уже «толстый клиент» и «тонкий сервер», то есть, вся основная нагрузка будет висеть на клиенте. А сервер станет обеспечивать лишь обмен сообщениями и сохранение правок (следя за их корректностью). Таким образом, «экспортировать» пад можно будет прямо локально (со всей историей и чатом, если захочется!), не создавая никакой нагрузки на сервер, причём даже после того, как сервер, например, упал. Для восстановления пада – достаточно будет загрузить экспорт в новый пад на том же или другом сервере (а что в этом случае делать с аккаунтами пользователей – отдельный вопрос). Впрочем, репликацию данных на запасной сервер тоже можно организовать, либо на уровне базы, либо прямо вот так, как подключение клиента (только не к конкретному паду, а как бы ко всему серверу). 16) В etherpad была система «ревизий», любое состояние можно было отметить как ревизию, и оно бы пометилось звездой, к которой можно перейти в истории. Но на самом деле, фактически хранится не состояние документа, а только _номер_ правки. Чтобы получить текст, нужно было запросить его у сервера. Чтобы сервер его выдал, ему было нужно вытащить из базы прошлую наиболее позднюю «ключевую точку» (где текст лежит целиком), и все правки к нему вперёд до запрошенного момента – применить их и получить текст заданной ревизии. Сервер делал ключевые точки раз в несколько десятков правок (сто чейнджсетов, кажется), и хранил целиком. Но на самом деле, для «протаскивания» одной правки через другую (то есть, чтобы мой текст применился к паду, в котором уже, например, пятнадцать человек успели что-то написать, пока я к серверу приконнектился) – серверу НЕ нужен тот «прошлый» текст в явном виде, ему хватит только истории правок за эти 15 изменений, и текущего самого последнего текста, который и будет обновлён. Значит, ключевые точки _вообще_ не требуется хранить. Но как тогда попросить, например, предыдущий вид текста, ведь придётся собирать все правки от момента создания пада? Нет, если вместе с каждой правкой хранить её «инверсию» – обратную правку, переводящую документ в прошлое состояние. Она довольно легко делается, типа – если мы что-то удаляем, то в инверсной правке будет вставка того, что тут было удалено, и с нужными атрибутами. И если хранить всё это также и на каждом клиенте (полная реплика базы пада), то нажатие кнопки «назад во времени» будет мгновенным без лагов, ведь клиент просто применит последнюю инверсию к текущему виду. И перемотка истории от начала будет элементарной, там документ известен, и он пуст. Это вот в середину истории сложно будет попасть, придётся перематывать к ней с того или другого конца, ну на производительность мы потом ещё посмотрим. 17) Сервер нужно писать как можно проще. Всё что он должен проверять – что «клиент не может сломать пад». Всё что клиент сможет испортить – только _текст_ в паде (это логично, раз мы даёт всем полный доступ к редактированию), но не его историю. И не цепочку правок – чтобы не получилось так, что сервер запомнил и записал в базу «невалидную» правку, от которой потом вылетят все остальные клиенты, когда попытаются у себя её применить и отобразить. Ведь у сервера-то нет «пада», у него вообще ничего нет, он с правками работает как с абстрактными сущностями, и не применяет их к паду физически (и не может гарантировать, что они вообще применяться). Поэтому я думаю, что если простейшая валидация какой-то правки не прошла – то ошибка возвращается клиенту, и тот обязан сделать реконнект, запросив последнюю версию текста и обновив вид (но наверное, не пытаясь протащить к нему тут глючную пользовательскую правку, которую сервер уже отверг один раз, а просто отбросить её). Таких ошибок быть не должно, но они всё равно будут, потому что браузер ещё тот глючара, и всегда есть некий маленький шанс, что расчёт на клиенте будет неверен (из-за того, что курсор-то редактирует разнообразный HTML). В онлайн-режиме такие ошибки не страшны (это будет что-то типа, скопировал кусок из Ворда – вставил, через секунду произошёл реконнект, и текст не вставился; вставляешь ещё раз – оп, вроде вставился…), а вот в оффлайн функционале это плохо, ведь тогда не смогут сохраниться вообще никакие сделанные изменения. Ну мы ещё посмотрим, что сейчас-то говорить. 18) «Временный сервер» – это такой режим, в котором пад _вообще_ не сохраняется на сервере. Его коллективно хранят все подключённые клиенты локально, а с помощью сервера они просто общаются. Правда, там придётся придумать какую-то хитрость, как определять приоритет одновременных правок (сейчас – то, что УЖЕ на сервере имеет приоритет перед любым добавляемым). Зато, это вот что даст: волонтёры смогут открывать серверы-хосты, вообще без баз данных. На них крутится только NodeJS, без записи в файловую систему, хранящий лишь мета-данные о подключённых клиентах. Текст ревизий они будут пересылать друг другу сами (с помощью сервера, типа multicast), и сами синхронизировать вид своего пада. До тех пор, пока хотя бы один клиент (честный, а не тролль – тут возникают те же внутренние проблемы доверия, как в торрент-сетях), хранящий это пад, подключён к серверу – любой другой пользователь сможет зайти и синхронизироваться. А с удобным экспортом-импортом, документ можно будет при необходимости, например, загрузить и на нормальный сервер (когда тот заработает, или ещё для чего-то). Вообще говоря, сделать рабочий прототип такой системы было бы довольно сложно, хотя бы потому что на клиенте будет часть серверного функционала. 19) Последнее, и самое интересное (причём именно то, что я как раз и реализовал, потому что challenging) – окраска изменённого текста в зависимости от его статуса. Таких статусов я насчитал четыре: • Новый вписанный локально текст, который ещё не отправлялся на сервер; • Отправленный на сервер кусок текста, он будет отмечен так, пока сервер не вернёт acknowledgment, что принял правку (фактически, «сохранено», sync); • Пришедший с сервера текст, вставленный другим пользователем – он только что появился, и окраску можно снять просто через некоторое время; • Удалённый кем-то текст. Единственный способ его покрасить – это НЕ удалять, но покрасить. А потом – удалить. Но знаете, что?.. Теперь мы переходим к математической модели операционных преобразований, которую вам было бы неплохо полностью понять, хотя бы потому что это просто интересно. Лучше всего она оказалась расписана вот тут: http://www.codecommit.com/blog/java/understanding-and-applying-operational-transformation , особенно их графики, без которых мне приходилось думать в терминах математических формул, какие были в спецификации easysync. Так вот. Правки собираются в цепочку. Каждая правка может быть применена только к КОНКРЕТНОМУ документу, к которому она была сделана. На выходе после каждой правки мы получаем новый документ. Если ко второму документу применяется ещё одна правка, то у нас есть цепочка из двух правок, и третий документ на выходе. Чтобы «фактически» увидеть правку – нам _нужен_ тот документ, к которому была применена первая из них. И в этом документе будут только операции «вставки», а не удаления, и не форматирования. Потому что, что же будет обозначать документ, в котором есть «удаление», что это за отрицательная длинна? Но именно такой документ получился бы, если скажем, применить правку «удалить 10 символов» к документу, в котором сейчас есть только два символа. – Потому что это была правка «не к нему», а к кому-то другому. Та-ак, во-от. Имея две правки в цепочке (то есть, если конец второй применяется к документу, полученному применением первой к «своему» документу) – их можно объединить в одну единую правку (это называется «композиция»), которую достаточно будет применить к первоначальному документу, и получить сразу результирующий, без промежуточного. Фишка в том, что для этого сам такой документ нам НЕ нужен! И конечно, композиция зависит от порядка цепочки, и нельзя вычислить композицию «второй правки с первой», потому что первая была применена же не к тому документу, который получился после второй. С другой стороны, правку можно инвертировать (и для этого нам _нужен_ первоначальный документ, если мы хотим получить фактический текст с атрибутами, а не просто количественное отношение вставляемого и удаляемого текстов, что тоже не лишено смысла). Композицию двух инвертированных правок можно посчитать в обратную сторону, да. Но самой главной операцией называется «трансформация»: она берёт две правки, который были сделаны к ОДНОМУ документу (и переводят его в разные состояния, в два других совершенно разных документа) – и на их основе вычисляет две другие правки. Такие, что одну из них можно применить к результирующему документу после первой правки, а другую – к документу после второй правки. Тогда и та и другая цепочки (состоящие из оригинальной и созданной правок) гарантированно приведут к ЕДИНОМУ (четвёртому) документу. То есть, каждый из воссозданных кусков, это преобразование («трансформация», протаскивание) оригинальной правки «через» другую, чтобы получить её отражение на чужом документе. На тех рисунках в статье они назвали это «проблемой ромба». И кстати, здесь задаётся «приоритет» одной из двух оригинальных правок, потому что только при согласовании приоритета можно получить одинаковый документ. (На самом деле, при обратных приоритетах получатся другие две виртуальные правки, которые точно так же дают единый документ, но уже другой). Таким образом, когда клиент хочет отправить новую правку на сервер – он говорит ему номер ревизии (ну, точки в истории), к которой применялась его оригинальная правка. Сервер вытаскивает из базы все ревизии от этого момента по последнюю, и считает их композицию. Протаскивает транформацией клиентскую правку через эту композицию, и получает такую новую правку, которая уже применяется к его текущему виду. Записывает её в базу, а в ответ высылает клиенту как раз композицию изменений (ну в моём усложнённом случае – весь массив истории, раз клиентам она тоже нужна локально). Теперь у клиента есть правки сервера, подходящие к той ревизии, к которой была сделана его исходная правка. НО! У него-то на экране «сначала» же была применена его собственная оригинальная правка? Значит, эту самую «правку сервера» он сперва должен протащить через свою правку, строя второе ребро ромба, чтобы она стала подходить к его документу. Однако, его документ уже изменился с тех пор – в нём был написан НОВЫЙ текст. И тогда, во-первых, нужно протащить серверную правку и через него, а во вторых – зеркально протащить свой этот новый текст через серверную правку, чтобы когда он впоследствии отправил её на сервер – она подходила к новому номеру ревизии (который сервер только что сообщил). …Это чертовски сложно реально понять, потому что кажется, что всё понял, а на самом деле – не понял почти ничего. Если я буду «разделять» входящие серверные правки на «вставлено» и «удалено» (чтобы временно окрасить их в разные цвета), то у меня очевидно образуется массив «на окраску», который нужно будет потихоньку обрабатывать – обесцвечивать вставленный, и наконец уничтожать удалённый текст. Это новое промежуточное состояние клиента: «недоприменённая» предыдущая правка сервера, и через неё нужно протаскивать все исходящие правки (и обходить входящими?), ведь у сервера-то текст удалён, а не покрашен. А если я хочу подкрашивать неотправленный текст – то потом мне нужно покрасить его ещё раз (перед отправкой), а за это время состояние пада может чёрти как измениться – проблема в том, что у неотправленных правок появляется два отображения: «локальное» как на экране (его надо помнить, чтобы потом снять окраску), и «серверное» (для отправки), в котором учтены и протащены все предыдущие серверные правки. Но самая жесть начинается тогда, когда сервер присылает ещё одну новую правку, а у нас в этот момент ещё не удалён по-настоящему текст с предыдущей его правки! Есть бомжинский вариант – быстро вхлопнуть его в документ, стерев подкрашенный удалённый текст «раньше времени», успешно применив новую серверную правку (которая создаст новую пару вставленного и удалённого текста). Но по-настоящему, я хочу, чтобы серверные правки «копились» в очереди, но при этом отображались все вместе: то есть, новый удалённый текст должен окрасится сразу, а не только после того, как исчезнет предыдущий. Я долго не мог понять, в чём проблема (и изрисовал много бумаги, пытаясь сообразить), но оказалось, что беда в том, что когда я разделил очередную серверную правку на две, но применил только одну из них – следующая его правка как бы применяется-то к их композиции, но я хочу отобразить её так, словно композиции не было, «протащить обратно» через вторую неприменённую половину. Но это не будет протаскивание, это не трансформация. Потом что трансформация – это две правки к одному документу, а у меня – две последовательные правки, которые я хочу применить в обратном порядке! Вообще, пахнет инверсией. В терминах ромба – у меня есть его «одна сторона» – левая или правая, а я хочу найти его противоположную сторону. Мы не находимся в вершине, и трансформация не работает. А… если его повернуть на бок, и считать первую правку, вернее, её инверсию – как вторую к «промежуточному» положению — можно ли будет вычислить две других? И да, и нет. С помощью некого подобия инверсии, я добился вычисления «верхнего» кусочка ромба, а потом, уже с помощью обычной трансформации – досчитал его нижний кусочек. Но у меня это работало _только_ для правок с удалением и пропуском без атрибуции (в смысле, когда первая правка любая, а во второй – удаление, как раз мой случай), и то пришлось покостылить – а именно, в инверсии первой правки, все вставляемые (то есть удалённые) символы я превратил в \0-нули (символ с кодом «0x00»), а потом в итоговом результате все такие вставки, которые вставляли эти самые нули (уж не знаю, как они туда просочились, в код оригинальной композиции и трансформации я ещё не вчитывался) – я менял на операции удаления. Это сработало!.. Но потом я подумал ещё и понял, что я хочу «транспозициию» (это что-то между трансформацией и композицией, ага) двух правок: чтобы A+B представить в виде B'+A', где новые правки сохранили бы свою сущность, где возможно. И я смог сам написать эту операцию, прямо по определению! Разобрав по случаям, что должно произойти, когда к одному и тому же месту применяются различные варианты вставка/пропуск/удаление, и что из этого должно записаться в каждую из двух результирующих правок. Теперь стало возможным «менять местами» приходящие правки, и я смог объединять свои недоудаления, вытащив оттуда недовставки, которые я теперь сразу могу вхлопнуть в пад. На всё это ушло так много времени, что я даже сомневаюсь, стоило ли оно того. Итак, пора показать результаты! http://klimaleksus.narod.ru/Files/6/EitherQuill_1.rar В папке «\static\» есть файл «index.html», его можно открыть в браузере прямо как есть. Нажмите на кнопку «local 3», откроется три пада. Они имитируют трёх клиентов, подключённых к единому серверу (который тоже симулируется кодом в браузере). Одна из моих опцией клиентской настройки – это, через сколько секунд отправлять правки на сервер. Два числа, минимум и максимум. Минимум означает, что если вы сделали правку, и больше ничего не делаете – то клиент отошлёт её на сервер через такое-то время. Если же вы допишите что-то ещё, то клиент отсрочит отправку, сперва объединив эти две правки, а потом отправив как одну цельную, опять же через «минимальное» время. Но если вы продолжите писать и писать, то отправка будет продолжать отсрочиваться. Пока не превзойдёт «максимальное» время, когда все текущие правки форсировано будут отосланы на сервер. Ну а для пущей симуляции реальных условий, я ввёл виртуальный «пинг до сервера» – это время, за которое отосланная правка достигает сервера, и ещё столько же придётся ждать ответа. Несмотря на все задержки, документ обязан идеально синхронизироваться у всех трёх клиентов. Итак: • первый клиент пишет фиолетовым, у него пинг до сервера 1 секунда, а время отправки – от 1 до 4 секунд. Это «медленный клиент»; • второй пишет зелёным, его пинг 0,15 секунды (цифра реального мира, кстати), а время отправки – от 0,1 до 0,2 секунды, это «быстрый клиент»; • третий пишет голубым, у него отсутствует пинг (1 мс, это типа «локальный клиент»), но сбалансированные времена отправки – 0,5 и 2 секунды. Только что написанный текст у любого клиента выделяется красным (цветом текста, не фона), а когда он отправляется на сервер – становится коричневым (и чёрным/никаким, когда сервер подтвердит, что принял). Входящий текст от других пользователей – отображается зелёным и подчёркнутым, а удаляемый другими – бордовым и зачёркнутым (сохраняя изначальный цвет фона, поэтому не видно, «кто удалил»). Через три секунды – форматирование вставки снимается, а удаление фактически исчезает из пада. (А ещё, я сразу запрограммировал подход «long-polling», то есть клиенты подают на сервер «провисающие» запросы, на которые сервер отвечает не сразу: он либо обрывает их где-то через 10 секунд и говорит «новых правок нет, шли ещё один такой же запрос», либо, при новой правке – возвращает по ним всем клиентам эту правку, а те принимают её, если она им ещё нужна. В дальнейшем, быть может, этот подход удастся заменить на обмен сообщениями в двусторонних сокетах). Можете попробовать поиграться и с форматированием (я не ограничивал настройки Quill, оставив почти всё по умолчанию, мы это потом будет настраивать отдельно). Автор изменения форматирования – никак не показывается, а просто применяется эффект. Если вам кажется, что вы печатаете явно дольше двух секунд, а по виду – текст всё никак не отправляется на сервер – значит, пад стал подтормаживать, и браузер неточно отсчитывает время (хотя у вас вряд ли такое будет, там достаточно просто двухядерного процессора, чтобы интерфейс браузера спокойно справлялся). В любом случае, эй, страница имитирует аж три пада сразу! Внизу страницы есть кнопка «test» – она запускает большой цикл спама в пады рандомных (но более-менее разумных) правок через случайные промежутки времени. Это «стресс-тест», нацеленный как раз на моё бесполезное выделение красным/коричневым/зелёным/бордовым. Это не «функциональный тест» (например, он не вставляет переносы строк, и скудно пользуется форматированием). Через некоторое время (после того как кнопка перестанет быть disabled), пады должны «устаканиться», перестав шевелиться. Точно время сказать нельзя, зависит от лагов. Но в итоге, нажмите на кнопку «compare» – она досконально сравнит текущее содержимое всех трёх падов (включая все атрибуты и цвета), и выведет на своём месте «FALSE», если найдены расхождения, и «TRUE», когда всё идеально синхронизировано. Мой код проходит этот тест! (Хотя во время разработки, он СТОЛЬКО раз проваливался…) Однако, пад всё ещё можно сломать, если вставлять в него всякую гадость, типа страниц рандомных веб-сайтов или громадных документов из ворда, с картинками и таблицами (сейчас Quill поддерживает и картинки, и таблицы, но и то и другое мы потом отключим, потому что капец как глючно). Маркированный список почему-то не работает (только нумерованный), хотя на сайте Quill – работает. Дело в том, что с некоторого времени разработчики занимаются «версией 2.0», где переделывают внутреннюю структуру кода. Я, вместо того, чтобы взять stable-ветку, взял этот самый develop. Видимо, в нём просто ещё не все до конца сделано. А взял я его, потому что именно в нём используется новая версия Delta-библиотеки. В строй версии у них ещё не было функции invert. Однако, новую Дельту они зачем-то вообще переписали на Typescript… Как и весь свой Parchment. Для перевода в javascript они используют рекомпилятор, но также дают и уже скомпилированные бинарники. Теперь я должен рассказать вам про NodeJS. Я не знаю, насколько хорошо вы знакомы с этой системой, но мне пришлось познакомиться вплотную (хотя у меня было несколько проектов на нём и ранее). Это такой javascript-движок, предназначенный для запуска без браузера. И у него есть доступ ко всему тому, к чему у браузера не было: к сокетам, к процессам, к файловой системе. Вот так, например, выглядит документация к файловому доступу: https://nodejs.org/api/fs.html Создатели NodeJS придумали к нему модульную систему, где каждый модуль (просто javascript-код, оформленный как отдельная библиотека) мог быть сделан любым независимым разработчиком, и загружен в общее хранилище. Модули – делают кучу полезных операций, которых просто нет в ванильном Node. Конечно, они создают новый функционал поверх изначального API, _либо_ – с использованием функционала других модулей. То есть, чтобы что-то сделать, нужен модуль, которому в свою очередь нужны ещё десять модулей, из которых четыре модуля хотят ещё по пять модулей, а одному нужно ещё 50, потому что он очень толстый и многофункциональный. А ещё они все могут быть разных версий… Поэтому они придумали «менеджер пакетов», NPM. Он умеет скачивать нужные модули, находить зависимости и решать конфликты (например, когда одному модулю нужен другой модуль определённой версии – он получает собственную копию, но если нескольким модулям требуется конкретно один и тот же – то он станет общим для них всех). Также он решает проблемы обновления модулей, и развёртывания проекта – чтобы модули скачались не «какие-то», а дословно тех версий, с которым проект разрабатывался. Получается, что проекту нужна всего пара модулей, а в итоге скачивается несколько сотен, потому что таковы зависимости, и с этим ничего не поделать. Вернее, можно кое-что: запаковать их. Например, с помощью Webpack, это такой модуль, который переваривает код проекта, и собирает ОДИН файл, в котором будут экспортированы те сущности, которые были нужны проекту (и будут «неявно» присутствовать все-все остальные промежуточные модули, под новыми именам и обёртками). А ещё, по дороге можно рекомпилировать тот код, который написан не на javascript (например, Typescript), или не той версии языка. О, кстати о версиях. Стандартный javascript (каким его знает Internet Explorer) – это очень старая штука, и на протяжении времени в него вносились изменения. Самый базовый, на данный момент – это ES5, он (самое заметное нововведение) стал содержать «функциональные методы» обработки массивов, типа расфурычивания («.forEach»), которые принимают анонимные функции, в которые потом передаются элементы массива. Это гораздо круче и удобнее, чем идти по массиву циклом, но это чуть менее производительно. Хорошая новость в том, что es5-версия языка – последняя, которую поддерживает моя любимая Opera12. Далее был придумал ES6 (он же, ES2015) – в нём добавили целую кучу новых фишек. Например, сокращённое создание анонимной функции «()=>{…}» (вместо «function(){…}»), а ещё его визитной карточкой стали Promises. Раньше, любую асинхронную работу нужно было выполнять, передавая в аргументах так называемую колбек-функцию (обычно называемую «callback» или cb), которая будет вызвана асинхронным кодом, когда задача будет выполнена. Если вы программировали на jQuery, например, то вы могли заметить, что код растёт не «вниз», а «вправо», каждый раз открывая новую «,function(){», и закрываясь «});». Так вот, в асинхронном коде всё ГОРАЗДО хуже. Теперь, с помощью Promises, можно было продолжать писать код вниз, просто добавлять .then(res=>{…}) снова и снова, чтобы проходить по цепочке асинхронных вызовов. Для упрощения этого даже созданы разные библиотеки. Но в целом, оба подхода – коллбечный и промисовый – одинаковы, просто оформлены по-другому. В многих случаях Promise удобнее чем callback, но в других – делать коллбеками куда проще и логичнее. Но революцией в языке стало внедрение async/await (в ES2017, кажется) – после этого асинхронный код стал таким же простым и понятным, как синхронный, и писать его одно удовольствие. Причём внутри всё это завязано именно на промисах, а от коллбеков придётся оказаться полностью (и обёртывать в промисы те, что остались не по твоей воле). Единственный недостаток в том, что этот синтаксис поддерживается только в САМЫХ новых браузерах, а для всех старых – нужен рекомпилятор (transpiler), который переварит новый синтаксис, и выплюнет набор костылей, которые делают то же самое на старом синтаксисе, и пригодны в старых браузерах. А ещё будет нужен «polyfill», это отдельный костыль-довесок, который воссоздаст в старых браузерах, например, функционал тех же Promise (быть может, с потерей производительности). Однако, в ES6 добавили ещё и нормальные «классы» (class), а не извращения с прототипами (prototype) объектов. Классы, на самом-то деле – удобные. Их писать удобно. Прототипы – пользоваться удобно, а писать – противно. Но именно они поддерживаются старыми браузерами (в которых, например, нету ни промисов, ни стрелочных функций). Так во-от… Delta и Parchment написаны на Typescript, но есть их прямая рекомпиляция в javascript (es5). А Quill – написан на ES6 (классами), и для кастомизации его базового функционала – проект нужно пересобрать через webpack. Возможно, с использованием Babel (это как раз транспайлер с ES6 на ES5), если мы хотим, чтобы он работал не только в «самых новых браузерах». (К слову, я пытался поднять Quill в OperaAC, но после трёх полифиллов и пары собственных костылей, опера выдала мне такую дикую ошибку, гуглинг которой сказал, мол, «ставьте патч на ядро Оперы, потому что в следующей версии Presto-движка этот баг уже исправлен» – но поскольку даже сейчас весь интерфейс пада отображался нереальным трешем – я просто оставил эти попытки). Так вот! Проблема в том, что сам NodeJS – тоже эволюционировал. Он долго был в нулевой версии (0.x.y), но потом – мажорные релизы пошли вверх. А последняя версия, которая работает на XP – это Node 5. Он поддерживает синтаксис ES6 только в «строгом режиме» ("use strict"), и то не полностью. (Сейчас последняя версия Node – кажется, 11-я). А многие модули – целятся только на «последние версии Node», спокойно юзают частичный ES6 (без строгого режима, например), и следовательно, не работают на Node 5, и на XP. Единственный нормальный способ с этим справиться – это собрать проект с Webpack и Babel, загрузить в него нужные модули, и собрать bundle (переведя в ES5), чтобы потом подгрузить его в старую версию Node. Очевидно, что паковать и компилировать придётся как раз-таки в новом ноде (например, Node 8), и на новой операционной системе. Короче, я поставил Linux (debian) в виртуальную машину! ^^ Опустим кучу подробностей, с чем мне пришлось столкнуться. Но я смог пересобрать Quill в единый bundle, который уже адекватно работал у меня на XP (причём Node-серверу оттуда нужна только Delta, а не Quill и не Parchment). Тем не менее, для разработки самого «сервера» пада, мне понадобился модуль Express, который, слава богу, всё ещё работает на Node 5: https://expressjs.com/en/api.html А значит, мне нужен и пакетный менеджер, npm. Но с ним у меня возникли проблемы ещё в прошлый раз, потому что сам он является «предустановленным модулем» со своими зависимостями, и хреново совместим с текущим положением дел (потому что нужен новый npm, а он нормально не работает в старом Node). Я скачал альтернативный пакетный менеджер Yarn, который был уже заранее запакован через webpack. Правда, даже в нём были куски неперевариваемого синтаксиса, но я руками их подправил, и теперь у меня есть нормальный, актуальный и очень быстрый пакетный менеджер, без засирания корневой папки «node_modules» самого node.exe! Вот моя сборка Node и Yarn, работающая на XP (и выше; хотя, для продакшена, конечно же, лучше будет сказать последнюю доступную версию, потому что производительность): http://klimaleksus.narod.ru/Files/6/NodeYarn.rar Чтобы заработало, её нужно положить куда-нибудь, что потом придётся либо добавить в PATH глобально, либо просто временно каждый раз при запуске проекта. Кстати, в моём архиве с падом, в корне есть файл «console.bat» – он как раз призван открыть консоль NodeJS, в него надо только путь как папке «NodeYarn» прописать, вместо того, что там сейчас. В консоли можно будет использовать и node-, и yarn-команды. (А ещё, изменить свойства окна, сделав буфер экрана побольше, и «сохранить для всех окон с тем же именем»). Теперь вы можете попробовать запустить мой настоящий сервер пада. В нод-консоли напишите «node eq.exe» в папке проекта. Если всё в порядке, на локалхосте на порту 2222 будет висеть express-сервер, на который можно зайти браузером. Ничего нового, он отдаст тот же самый index.html, который вы видели до этого. Но теперь там будут работать две другие кнопки, «remote 3» и «remote + 1». Первая из них откроет те же самые три пада, что до этого, только разница в том, что теперь всё сохраняется на сервере, а не в браузере (и локальные симуляции пингов перестают действовать). Если вы закроете окно, и зайдёте другим браузером – состояние автоматически будет последним. (Заход одновременно с нескольких вкладок на страницу с тремя падами сломает long-polling автоматическое обновление, потому что у них будут совпадающие ID клиентов; тем не менее, система вроде бы останется более-менее юзабельной, если продолжать редактировать текст). Вторая кнопка – откроет один большой пад, четвёртый. Это как раз чтобы со второго браузера непосредственно проверить. Этот клиент пишет синим. А ещё, там есть кнопочка, доказывающая локальное сохранение истории: нажмите «replay», и все ревизии пада будут быстро, одна за другой отображены в паде. (Защиты от дурака, по-моему, там нет, так что не вносите новые правки в этот момент). Чтобы посмотреть, как история хранится локально, откройте консоль браузера, и найдите там что-то типа «Resources – IndexedDB». Но сейчас, сохранение истории у меня очень глупое, просто proof-of-concept. Если что, я использовал библиотеку Dexie: https://dexie.org/docs/API-Reference А ещё, валидацию JSON и на сервере, и на клиенте – через AJV: https://github.com/epoberezkin/ajv (Кстати, потом все такие библиотечки нужно будет включить в bundle, чтобы загружался единый скрипт, а не целая куча). Сервер хранит историю и у себя в «базе», но пока что фиктивно, это просто показуха. На самом деле, всё лежит в памяти, и я дальше объясню, почему. JSON-дельты записываются в файл «./db/pad.JL». Рядом есть файл «pad.ji» – это индексы (кстати, в 36-ричной системе..), то есть байтовые смещения к строкам, с которых начинаются объекты в главном файле. Это позволяет быстро (и очень хитро) вытащить из того файла любой объект по номеру. Это я сделал при помощи своей собственной новой библиотеки, ObjectLogger. И я написал её на ES6. Она автоматически отрывает файлы, когда они нужны, закрывает, когда не нужны, кеширует некоторые объекты в памяти, пока к ним есть интерес, и наоборот удаляет их, если прошло много времени с момента последнего запроса. Для этого мне пришлось сначала написать библиотеки «PairedFile» (для удобного управления сразу парой файлов), «Queue» (там, короче, дикая цепочка считанных кусочков и ответы на запросы; код всего в целом ОЧЕНЬ сложный, и его надо полностью прокомментировать…), и «VirtualList» (который, кстати, там ещё в одном месте нужно применить, но я боюсь что-то сломать). А ещё, моя библиотечка может сама восстановить эти файлы базы, если они вдруг оказались повреждены (сервер упал, а файлы не дописались, или хотя бы – рассинхронизировались между собой), и в какой-то момент мне понадобился простенький парсер частично-повреждённого JSON, который я тоже написал («JsonParser»). Вообще, в этой моей «собственной базе данных» довольно много костылей, хаков и хитростей… На неё ушло почти всё остальное время, за которое я не занимался попытками «зачеркнуть удалённый текст», ага. Всё это написано на ES6, но при этом – через асинхронный механизм callback. Далее, в файле ./static/index.js – лежит клиентская настройка Quill, а рядом в «eitherquill.js» – весь основной мой движок, разбитый на Server, Client и функции-помощники. Он написан на ES5 (через прототипы), но при этом используется и в клиенте (локальный виртуальный сервер, и весь клиентский движок), и на моём сервере (только объект Сервера, но с чуть-другими настройками). …Изначальный прикол в том, что я почему-то подумал, что Node 5 НЕ поддерживает ES6! И первое, что я написал – вот этих клиент-сервер, вообще без Node. Далее я взялся за свою реализацию джисоновой базы данных, и так запарился с прототипами и анонимными функциям, что нереально захотел воспользоваться новым синтаксисом. А он – поддерживается!.. При этом, кода было уже написано где-то на 1 000 строк. Ну… пришлось переписывать, с ES5 на ES6, раз Node-то поддерживает. Но клиентский (вернее, серверный, но его мы и клиенту отдаём, для тестирования) код я менять не стал, мне показалось, что не понадобится. Всё было написано ВООБЩЕ без Promise, а только с коллбеками. Причём, у Dexie (обёртка IndexedDB) была своя реализация промисов, но я работал с ней как с blackbox, просто вызывая необходимые мне функции так, как того просила документация к библиотеке. Моей главной ошибкой стало то, что когда я писал код серверной части, обрабатывающей long-polling, и вообще всех входящих дельт – хоть код и был немного асинхронным (с коллбеками), но при этом, обращения к ИСТОРИИ правок (и к номеру ревизии, и к «документу») – были сделаны синхронно, как простые чтения массива. Я почему-то не задумался об этом изначально. А теперь, когда уже всё написано – я захотел подцепить туда свою, наконец готовую и проверенную собственную базу данных – я не смог! Потому что, во-первых, оказалось, что мой текущий синхронный код нельзя просто так взять и превратить в синхронный. Это нарушает порядок операций, особенно в сочетании с другими клиентскими запросами. Например, клиент попросил сервер принять новую правку. Сервер, прежде чем ПРИНЯТЬ её, должен запросить текущее состояние этого пада в базе, чтобы вообще понять, есть ли такой пад, и какая там сейчас последняя ревизия. Потом, объединить её с историей, и записать обратно в базу. Но к тому моменту, как запрос будет готов к тому, чтобы отослать обратно этому же клиенту – могла УЖЕ придти правка от другого пользователя. Что же в этот момент должно произойти? Сервер обязан как ни в чём не бывало, ответить первому юзеру, что его новая ревизия, дескать, «такая-то», скрыв факт, что реально сейчас уже другая (потому что новую ревизию этот клиент узнает уже лишь при следующем запросе), а значит – сервер должен отдельно запоминать «целевую ревизию» для каждого клиента, а не просто возвращать текущий head базы, как это сделано сейчас. (Я имею в виду, что код «придётся переписывать», а не просто переводить на асинхронный вид). Во-вторых, у меня напрочь ломается весь long-polling, потому что я не учёл, что когда два пользователя подключены к _разным_ падам – они вообще не должны никак взаимодействовать между собой, и появление новых правок в первом паде – не должно отключить второго клиента. А сейчас у меня всё завязано на том, что любая правка – отсылается _всем_ клиентам, и полностью очищает массив поллинга. (А думать следует не терминами костылей, а наоборот – стабильности, производительности и масштабируемости). И в-третьих, асинхронность «базы» по любому поводу (ну например, нужно вот будет серверу достать сообщение из чата – оно же в базе лежит, а не в памяти у него, значит его сначала считать надо, а за это время в этот чат может десять новых сообщений прилететь; база-то конечно всё аккуратно сохранит и выдержит, а сервер не должен запутаться, что и кому отсылать) значительно усложняет внутреннюю логику сервера. Попытавшись написать очередной callback, я понял, что не спасут меня даже промисы… Единственный выход – переходить на синтаксис async/await! Но – да, он не поддерживается в Node 5. Зато в нём уже поддерживаются функции-генераторы и их yield, а это почти то же самое, что async, там просто маленького бабельского костыля не хватает… Короче, я скачал standalone Babel, который можно загрузить в Node 5, и прямо оттуда рекомпилировать async-код в ES6. Или даже в ES5, чтобы отдать браузеру… (Правда, этот транспайлер ОЧЕНЬ медленный, и загружается + компилирует больше 5 секунд – для продакшена это слишком много, значит шаг компиляции придётся повторять в development, каждый раз при изменении кода). Я не был уверен, что же быстрее – мои коллбеки, или бабельский асинх. Тогда я написал микротекст с кучей коллбеков, чтобы рекомпилировать его, и численно измерить урон производительности. …ТАК АСИНХ БЫСТРЕЕ!! Или я чего-то не понял, или yield в генераторах реально быстрее, чем куча коллбеков. Вот чёрт. Это значит, что async будет быстрее не только в новых версиях Node (читал про 10-ю версию, что они как раз именно эту асинхронность сильно оптимизировали по скорости), но и даже просто так, даже в моём, даже после рекомпиляции. Но… если уже официально делать рекомпиляцию ВСЕГО серверного кода с ES2017 (то есть, переписать всё на async и другие плюшки) – то что мешает тогда переписать и _клиентский_ код хотя бы на ES6 (чтобы, чёрт возьми, в классах программировать, и стрелочных функциях. Я, конечно, не люблю классы – но прототипы я не люблю гораздо сильнее!), а потом рекомпилировать для продакшена!? Для этого нужно, во-первых, поменять build-систему (вернее, _создать_ её), в которую запихнуть Babel; а во-вторых, переписать ВЕСЬ текущий код. Абсолютно весь (особенно моей базы данных, и всех её очередей), потому что на асинхи нужно переходить сразу везде, а для этого – _все_ коллбеки надо менять на промисы. Код станет не только проще и понятнее, но и на удивление, быстрее. …А там, это, ну… где-то уже 2 000 строчек кода. И если бы я прямо сейчас начал его переписывать, я смог бы написать обо всех результатах только ещё через месяц, если не больше. Но переписать всё равно надо, это понятно. Просто сейчас уже нельзя «добавлять фишки» (хотя какие там фишки, его б хотя бы до приемлемо-рабочей бета версии довести, а пока это только альфа, если вообще не концепт), нужно сначала всё починить. К тому же, у меня там сейчас отвратительная обработка ошибок (например, сервер падает от багов клиента – это, кстати, специально, потому что сейчас я должен сразу видеть все баги в любой части кода; а на продакшене нужно будет делать код гораздо более стабильным и аккуратным, чем он есть сейчас), особенно в базе: что делать, если файл не открылся, или в него не записалась инфа? Даже если сообщить об этом серверу – чем он сможет помочь, пойдёт клиента обрадует, что ли? Вообще, рекомендуют просто умирать и перезапускать node.exe, надеясь что в следующий раз файл откроется. А для этого нужен процесс-супервизор (и такие есть, надо просто почитать и разобраться). И вообще, я потом хочу вынести код базы данных в отдельный процесс (тоже node, естественно), чтобы более равномерно распределить нагрузку на ядра процессора: пусть сервер занимается дельтами и коннектами, а база – только файловым вводом-выводом. И если только база упадёт, это не отключит клиентов (и не разлогинит их, потому что токены авторизации я намерен хранить в памяти, вообще никуда не записывая; с другой стороны, даже если клиентов отключит – клиентский код должен будет переподключиться к серверу, и заново залогиниться там в автоматическом режиме). В общем, мой вопрос в целом вот какой: у вас на примете есть другие заинтересованные javascript/NodeJS программисты? Потому что я уже сварился, по-моему. А сделать нужно будет ещё очень многое. Например, я хотел бы полностью переписать Delta-библиотеку, чтобы во-первых, добавить в неё длины документов (по ним легче валидировать), и быть может, некие _хеши_ ревизий (чтобы можно было однозначно проверить, подходит ли дельта к данному документу), а также – укоротить названия ключей-операций (особенно слово «attributes»), которые повторяются _постоянно_, и занимают место – в базе, в памяти и в запросах. На сервере и клиенте тоже многих аспектов пока не хватает. Например, нужно ограничить размер одной дельты, потому что: – на приём большого текста уйдёт асинхронное серверное время; – на парсинг большого JSON потратится _синхронное_ время (а это самое плохое); – на трансформацию больших дельт также уйдёт синхронное время; – на чтение большого объекта из базы уйдёт асинхронное время и много памяти. А что делать клиенту, у которого дельта оказалась больше, чем лимит? По-честному, когда сервер скажет ему об этом, клиент должен будет как-то поделить свою правку на несколько кусочков, и запостить каждый по отдельности. Но так как это асинхронный процесс, может возникнуть куча стычек с другими фишками пада, и над этим надо будет плотно поработать. О, вопрос: клиент удалил всё содержимое пада, теперь его правка – очень маленькая, «{ops:[{delete:LENGTH;}]}» – но когда _сервер_ посчитает её инверсию – у него начнутся проблемы, и по-честному, клиент должен грохать не весь пад целиком, а частями. Но как серверу (и тем более, клиенту) это понять, не вычислив саму инверсию? Наверное, по количеству стираемых символов. А ещё, клиенты должны переподключаться к серверу, если тот недоступен. По нескольку раз… Да, кстати, сейчас в паде не работает отмена. Там надо что-то придумывать. Я не блокировал ту отмену, что была у Quill, но работает она явно неправильно. Вроде, групповая отмена должна быть хорошо сделана у FirePad (который был третьим кандидатом), там даже какая-то функция в ихней дельте есть, которая эмпирически определяет, сколько отдельных правок должно схлопнуться в один шаг отмены (а вот Quill свою отмену запоминает просто по прошедшему времени). Палитра цветов у меня ещё тоже не настроена, и я пока даже не придумал, где (на сервере) эти самые цвета хранить. Где-то среди настроек пада, видимо. О, забыл сказать, что в папке «\tests_and_trash\», кроме собственно тестов и мусора (и standalone-бабеля) есть два дополнительных проекта: «eitherquill-bundle» и «webpack-bundle». Оба из них нужно запускать на новом Node, и со стандартным NPM. Сначала в каждом из них придётся выполнить «npm install», и скачать суммарно под 50 Мб зависимостей, но что поделать. Теперь, в «eitherquill-bundle» есть «EitherQuillClient.js» и «EitherQuillServer.js» – это просто агрегационные библиотеки (написанные в модульном режиме ES6), которые достают нужные классы из нужных пакетов (дельту, quill, и так далее – потом добавим сюда все недостающие) и перепаковывают их в собственный экспорт. Сам по себе этот проект не делает ничего. А в «webpack-bundle» установлены Webpack и Babel, а также есть файл «index.js», которым инициализируется webpack+babel, и включается сборка файлов из «../eitherquill-bundle/» – тех двух, клиенсткого и серверного. Потом проект положит их в dist\ (а запускается просто «node index.js»). Уже скомпилированные – лежат выше, но в моём текущем коде используется просто «bundle.js», который я похожим образом собрал ранее и ссыканул менять. test-client.html и test-server.js (первый для браузера, второй для Node) просто считают эти сборки и выплюнут в консоль то, что в них видно (или кучу ошибок, если что-то не так). Напоследок, вот картиночка со сканами испорченных мною тетрадных листов: http://klimaleksus.narod.ru/Files/T/eitherquill.jpg _______________________________________ UPD: > в вашей инструкции была ошибка. Хорошо, что я смог догадаться. Не node.exe eq.exe , а node.exe eq.js А, да, не туда .exe добавил. Оказывается. _______________________________________ UPD 2: Мне нужны тестировщики, и особенно нужны юнит-тексты на модули. Сегодня как раз рефакторил/комментировал свой класс «JsonParser». Думаю, эх, надо бы к нему тесты написать! Потом, думаю, да он такой простой, чё их писать, всё равно ничего не выявят, и изменений в него уже вносить всё равно не придётся, какой прок от готовых тестов? Значит – нужно заняться другими классами. Сло-ожными… Ладно-ладно, уговорился, делаю тесты. Сделал. ПРОВАЛЕНЫ!! Почему!? Потому что баг есть! Я проверяю, экранирована ли кавычка в строке – но я не учёл, что сам экран может быть тоже экранирован (\\" против \"), и там от чётности зависит. Но нельзя линейно идти назад, ведь следует – рассматривать экран как ещё один «интересный» символ. И код пришлось значительно доработать. А потом – заново откомментировать изменившиеся строки Прокомментировать тесты, и готово, да. Ну что, будете смотреть? http://klimaleksus.narod.ru/Files/6/JsonParser.zip Для запуска (в моей NodeYarn-консоли – положите эти файлы рядом, можно с заменой) выполните «node JsonParser_Test.js». А ещё я узнал сегодня, что цикл for(let i=0,n=a.length;i{…});» просасывает for-циклу; но да – он просасывает гораздо, гораздо хуже, ну хоть тут я не ошибся) Видать, там что-то хитрое в оптимизации глубины джаваскрипта – видимо, если код «для человека» более читаемый, то он и для компилятора более читаемый: тот видит, что от 0 до длинны массива, значит это «обход массива»; а попытка сэкономить на ОДНОМ действии привела к том, что компилятор такой, «хм, тут счётчик меняется прямо в условии, инкремента вообще нет, хрень какая-то а не цикл, ну нафиг, лучше не буду ничего оптимизировать, здесь кто-то и без меня постарался». _______________________________________ UPD 3: Собрал среду для компиляции кода через Babel: http://klimaleksus.narod.ru/Files/6/EitherQuill_build.zip Написал дополнительный главный файл, который при каждом запуске приложения будет проверять дату модификации исходников, и вызывать перекомпиляцию тех, который были изменены! (На продакшене, запуск готового кода конечно же можно будет производить в обход этого метода). Также наладил в ней назначения каталогов: • «./src/» – там будет лежать код, который нужно перекомпилировать из ES2017 (async-await) в ES6. А быть может, и в ES5 для клиента, потом ещё посмотрим. • «./bin/» – туда скрипт будет ложить результаты компиляции, то есть тек скрипты, которые NodeJS будет фактически запускать • «./lib/» – сюда положим такие файлы, которые не нуждаются в компиляции, потому что не являются как таковой частью приложения. Сейчас тут сам Babel и вот этот новый скрипт для его вызова. Файл «console.bat» можете заменить на свой, с правильным путём. Теперь можете выполнить в консоли: «yarn start» (это равносильно «yarn run "start"», или «npm run start», потому что я в "package.json" прописал скрипт "node ./lib/Start.js"). Файлы из ./src/ должны скомпилироваться в ./bin/. Откройте их и проверьте: они будут очень няшно минифицированы. Повторный запуск пройдёт быстрее, потому что не будет повторять компиляцию (и даже не загрузит Babel), ведь файлы не изменены. Если вы измените текст в любом файле в ./src/ – то пре следующем выполнении он будет перекомпилирован. А если удалите какой-то файл – то будет удалён он же и из ./bin/. (Сейчас мой код ещё и «запускает» все полученные файлы кроме оканчивающихся на «_test.js», чтобы сразу же была видна ошибка, если Babel выдает неперевариваемый файл). Чтобы форсировать перекомпиляцию всех файлов – выполните ту же команду, но вместо «start» напишите в ней «build» (это другой мой скрипт). Также, если режим без минификации: замените «start» на «debug» – появится файл «debug.tmp», и теперь все файлы будут компилироваться без минификации кода (только комментарии отрезаются), даже в обычном режиме. Можете тоже проверить. Мой новый скрипт, который всё это делает – лежит в «./lib/Run.js», я уже полностью на русском прокомментировал его, пичитайте. А в папке ./src/, кроме предыдущего «JsonParser.js» появился и обновлённый «VirtualList.js» – тоже с комментариями, но без своего тестирующего скрипта. (Кстати, не напишите ли для него парочку тестов?..) Ну пока что да, оба класса синхронные, и их компиляция фактически ничего не даёт. Но скоро я точно так же обновлю-прокомментирую и остальные классы проекта… _______________________________________