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


Живой пример с расчётом кредита:


Кредитный калькулятор


А дальше я расскажу, как сотворить такое же за вечер используя фреймворк $mol...


Это что за покемон?


$mol — современный фреймворк для быстрого создания кроссплатформенных отзывчивых веб-приложений. Он базируется на архитектуре MAM устанавливающей следующие правила для всех модулей:


  • Модуль — это директория, содержащая исходные коды.
  • Исходные коды могут быть на самых разных языках.
  • Все языки равноправны в рамках модуля.
  • Модули могут образовывать иерархию.
  • Имя модуля жёстко соответствует пути к нему в файловой системе.
  • Между модулями могут быть зависимости.
  • Информация о зависимостях модуля получается статическим анализом его исходных кодов.
  • Любой модуль можно собрать как набор независимых бандлов на разных языках (js, css, tree...).
  • В бандлы попадают только те модули, что реально используются.
  • В бандл попадают все исходные коды модуля.
  • У модулей нет версий — всегда используется актуальный код.
  • Интерфейс модулей должен быть открыт для расширения, но закрыт для изменения.
  • Если нужен другой интерфейс — нужно создать новый модуль. Например /my/file/ и /my/file2/. Это позволит использовать оба интерфейса не путаясь в них.

Рабочее окружение


Начать разработку на $mol очень просто. Вы один раз разворачиваете рабочее окружение и далее клепаете приложения/библиотеки как пирожки.


Для начала вам потребуется установить:



Если вы работаете под Windows, то стоит настроить GIT, чтобы он не менял концы строк в ваших исходниках:


git config --global core.autocrlf input

Теперь следует развернуть MAM проект, который автоматически поднимет вам девелоперский сервер:


git clone https://github.com/eigenmethod/mam.git
cd mam
npm install
npm start

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


Как видите, начать разрабатывать на $mol очень просто. Основной принцип MAM архитектуры — из коробки всё должно работать как следует, а не требовать долгой утомительной настройки.


Каркас приложения


Для конспирации наше приложение будет иметь позывной $mol_app_calc. По правилам MAM лежать оно должно соответственно в директории /mol/app/calc/. Все файлы в дальнейшем мы будем создавать именно там.


Первым делом создадим точку входа — простой index.html:


<!doctype html>
<html style="height:100%">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1">
        <link href="-/web.css" rel="stylesheet"/>
    </head>
    <body mol_view_root="$mol_app_calc">
        <script src="-/web.js" charset="utf-8"></script>
    </body>
</html>

Ничего особенного, разве что мы указали точку монтирования приложения специальным атрибутом mol_view_root в котором обозначили, что монтировать надо именно наше приложение. Архитектура $mol такова, что любой компонент может выступать в качестве корня приложения. И наоборот, любое $mol приложение — не более, чем обычный компонент и может быть легко использовано внутри другого приложения. Например, в галерее приложений.


Обратите внимание, что мы уже сразу прописали пути к скриптам и стилям — эти бандлы будут собираться автоматически для нашего приложения и включать в себя только те исходные коды, что реально ему необходимы. Забегая вперёд стоит заметить, что общий объём приложения составит каких-то 36KB без минификации, но с зипованием:


Network timeline


Итак, чтобы объявить компонент, который будет нашим приложением, нам нужно создать файл calc.view.tree, простейшее содержимое которого состоит всего из одной строчки:


$mol_app_calc $mol_page

Второе слово — имя базового компонента, а первое — имя нашего, который будет унаследован от базового. Таким образом каждый компонент является преемником какого-либо другого. Самый-самый базовый компонент, от которого происходят все остальные — $mol_view. Он даёт всем компонентам лишь самые базовые стили и поведение. В нашем случае, базовым будет компонент $mol_page представляющий собой страницу с шапкой, телом и подвалом.


Из calc.view.tree будет автоматически сгенерирован TypeScript класс компонента и помещён в -view.tree/calc.view.tree.ts, чтобы среда разработки могла его подхватить:


namespace $ { export class $mol_app_calc extends $mol_page {
} }

Собственно, сейчас приложение уже можно открыть по адресу http://localhost:8080/mol/app/calc/ и увидеть пустую страничку c позывным в качестве заголовка:


Пустой $mol_page


Синтаксис view.tree довольно необычен, но он прост и лаконичен. Позволю себе процитировать один из отзывов о нём:


Синтаксис tree очень легко читать, но нужно немного привыкнуть и не бросить всё раньше времени. Мой мозг переваривал и негодовал около недели, а потом приходит просветление и понимаешь как сильно этот фреймворк упрощает процесс разработки. © Виталий Макеев

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


У каждого компонента есть свойство sub(), которое возвращает список того, что должно быть отрендерено непосредственно внутри компонента. У $mol_page туда рендерятся значения свойств Head(), Body() и Foot(), которые возвращают соответствующе подкомпоненты:


$mol_page $mol_view
    sub /
        <= Head $mol_view
        <= Body $mol_scroll
        <= Foot $mol_view

В данном коде опущены детали реализации подкомпонент, чтобы была видна суть. Объявляя подкомпонент (он же "Элемент" в терминологии БЭМ) мы указываем его имя в контексте нашего компонента и имя класса, который должен быть инстанцирован. Созданный таким образом экземпляр компонента будет закеширован и доступен через одноимённое свойство. Например, this.Body() в контексте нашего приложения вернёт настроенный экземпляр $mol_scroll. Говоря паттернами, свойство Body() выступает в качестве локальной ленивой фабрики.


Давайте преопределим свойство sub(), чтобы оно возвращало нужные нам компоненты:


$mol_app_calc $mol_page
    sub /
        <= Head -
        <= Current $mol_bar
        <= Body $mol_grid

Тут мы оставили шапку от $mol_page, добавили $mol_bar в качестве панельки редактирования текущей ячейки, в качестве тела страницы использовали $mol_grid — компонент для рисования виртуальных таблиц, а подвал так и вовсе убрали, так как он нам без надобности.


Давайте взглянем, как изменился сгенерированный класс:


namespace $ { export class $mol_app_calc extends $mol_page {

    /// sub / 
    ///     <= Head - 
    ///     <= Current - 
    ///     <= Body -
    sub() {
        return [].concat( this.Head() , this.Current() , this.Body() )
    }

    /// Current $mol_bar
    @ $mol_mem
    Current() {
        return new this.$.$mol_bar
    }

    /// Body $mol_grid 
    @ $mol_mem
    Body() {
        return new this.$.$mol_grid
    }

} }

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


Возможно вы обратили внимание на то, что объекты создаются не прямым инстанцированием по имени класса new $mol_grid, а через this.$. Поле $ есть у любого компонента и возвращает глобальный контекст или реестр, говоря паттернами. Отличительной особенностью доступа ко глобальным значениям через поле $ является возможность любому компоненту переопределить контекст для всех вложенных в него на любую глубину компонентов. Таким образом $mol в крайне практичной и ненавязчивой форме реализует инверсию контроля, позволяющую подменять реализации использующиеся где-то в глубине переиспользуемого компонента.


Формирование таблицы


Что ж, давайте нарастим немного мясца и настроим вложенные компоненты под себя: гриду нужно объяснить, какие у нас будут идентификаторы столбцов, какие идентификаторы строк, а также списки ячеек в шапке и теле таблицы.


Body $mol_grid
    col_ids <= col_ids /
    row_ids <= row_ids /
    head_cells <= head_cells /
    cells!row <= cells!row /

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


/// Body $mol_grid 
///     col_ids <= col_ids - 
///     row_ids <= row_ids - 
///     head_cells <= head_cells - 
///     cells!row <= cells!row -
@ $mol_mem
Body() {
    const obj = new this.$.$mol_grid
    obj.col_ids = () => this.col_ids()
    obj.row_ids = () => this.row_ids()
    obj.head_cells = () => this.head_cells()
    obj.cells = ( row ) => this.cells( row )
    return obj
}

Как видите, мы просто переопределили соответствующие свойства вложенного компонента на свои реализации. Это очень простая, но в то же время мощная техника, позволяющая реактивно связывать компоненты друг с другом. В синтаксисе view.tree поддерживается 3 типа связывания:


  • Левостороннее (как в коде выше), когда мы указываем вложенному компоненту какое значение должно возвращать его свойство.
  • Правостороннее, когда мы создаём у себя свойство, которое выступает алиасом для свойства вложенного компонента.
  • Двустороннее, когда указываем вложенному компоненту читать из и писать в наше свойство, думая, что работает со своим.

Для иллюстрации двустороннего связывания, давайте детализируем панель редактирования текущей ячейки:


Current $mol_bar
    sub /
        <= Pos $mol_string
            enabled false
            value <= pos         <= Edit $mol_string
            hint \=
            value?val <=> formula_current?val \

Как видно оно у нас будет состоять у нас из двух полей ввода:


  • Координаты ячейки. Пока что запретим их изменять через свойство enabled — оставим этот функционал на будущее.
  • Поле ввода формулы. Тут мы уже двусторонне связываем свойство value поля ввода и наше свойство formula_current, которое мы тут же и объявляем, указав значение по умолчанию — пустую строку.

Код свойств Edit и formula_current будет сгенерирован примерно следующий:


/// Edit $mol_string 
///     hint \=
///     value?val <=> formula_current?val -
@ $mol_mem
Edit() {
    const obj = new this.$.$mol_string
    obj.hint = () => "="
    obj.value = ( val? ) => this.formula_current( val )
    return obj
}

/// formula_current?val @ $mol_mem
formula_current( val? : string , force? : $mol_atom_force ) {
    return ( val !== undefined ) ? val : ""
}

Благодаря реактивному мемоизирующему декоратору $mol_mem, возвращаемое методом formula_current значение кешируется до тех пока пока оно кому-нибудь нужно.


Пока что у нас было лишь декларативное описание композиции компонент. Прежде чем мы начнём описывать логику работы, давайте сразу объявим как у нас будут выглядеть ячейки:


Col_head!id $mol_float
    dom_name \th
    horizontal false
    sub / <= col_title!id -
Row_head!id $mol_float
    dom_name \th
    vertical false
    sub / <= row_title!id -
Cell!id $mol_app_calc_cell
    value <= result!id     selected?val <=> selected!id?val false

Заголовки строк и колонок у нас будут плавающими, поэтому мы используем для них компонент $mol_float, который отслеживает позицию скроллинга, предоставляемую компонентом $mol_scroll через контекст, и смещает компонент так, чтобы он всегда был в видимой области. А для ячейки заводим отдельный компонент $mol_app_calc_cell:


$mol_app_calc_cell $mol_button
    dom_name \td
    sub /
        <= value     attr *
        ^
        mol_app_calc_cell_selected <= selected?val false
        mol_app_calc_cell_type <= type?val     event_click?event <=> select?event null

Этот компонент у нас будет кликабельным, поэтому мы наследуем его от $mol_button. События кликов мы направляем в свойство select, которое в дальнейшем у нас будет переключать редактор ячейки на ту, по которой кликнули. Кроме того, мы добавляем сюда пару атрибутов, чтобы по особенному стилизовать выбранную ячейку и обеспечить ячейкам числового типа выравниванием по правому краю. Забегая верёд, стили для ячеек у нас будут простые:


[mol_app_calc_cell] {
    user-select: text; /* по умолчанию $mol_button не выделяемый */
    background: var(--mol_skin_card); /* используем css-variables благодаря post-css */
}

[mol_app_calc_cell_selected] {
    box-shadow: var(--mol_skin_focus_outline);
    z-index: 1;
}

[mol_app_calc_cell_type="number"] {
    text-align: right;
}

Обратите внимание на одноимённый компоненту селектор [mol_app_calc_cell] — соответствующий атрибут добавляется dom-узлу автоматически, полностью избавляя программиста от ручной работы по расстановке css-классов. Это упрощает разработку и гарантирует консистентность именования.


Наконец, чтобы добавить свою логику, мы создаём calc.view.ts, где создаём класс в пространстве имён $.$$, который наследуем от одноимённого автоматически сгенерированного класса из пространства имён $:


namespace $.$$ {
    export class $mol_app_calc_cell extends $.$mol_app_calc_cell {
        // переопределения свойств
    }
}

Во время исполнения оба пространства имён будут указывать на один и тот же объект, а значит наш класс с логикой после того как отнаследуется от автогенерированного класса просто займёт его место. Благодаря такой хитрой манипуляции добавление класса с логикой остаётся опциональным, и применяется только, когда декларативного описания не хватает. Например, переопределим свойство select(), чтобы при попытке записать в него объект события, оно изменяло свойство selected() на true:


select( event? : Event ) {
    if( event ) this.selected( true )
}

А свойство type() у нас будет возвращать тип ячейки, анализируя свойство value():


type() {
    const value = this.value()
    return isNaN( Number( value ) ) ? 'string' : 'number'
}

Но давайте вернёмся к таблице. Аналогичным образом мы добавляем логику к компоненту $mol_app_calc:


export class $mol_app_calc extends $.$mol_app_calc {
}

Первым делом нам надо сформировать списки идентификаторов строк row_ids() и столбцов col_ids():


@ $mol_mem
col_ids() {
    return Array( this.dimensions().cols ).join(' ').split(' ').map( ( _ , i )=> this.number2string( i ) )
}

@ $mol_mem
row_ids() {
    return Array( this.dimensions().rows ).join(' ').split(' ').map( ( _ , i )=> i + 1 )
}

Они зависят от свойства dimensions(), которое мы будем вычислять на основе заполненности ячеек, так, чтобы у любой заполненной ячейки было ещё минимум две пустые справа и снизу:


@ $mol_mem
dimensions() {

    const dims = {
        rows : 2 ,
        cols : 3 ,
    }

    for( let key of Object.keys( this.formulas() ) ) {
        const parsed = /^([A-Z]+)(\d+)$/.exec( key )

        const rows = Number( parsed[2] ) + 2
        const cols = this.string2number( parsed[1] ) + 3

        if( rows > dims.rows ) dims.rows = rows
        if( cols > dims.cols ) dims.cols = cols
    }

    return dims
}

Методы string2number() и number2string() просто преобразуют буквенные координаты колонок в числовые и наоборот:


number2string( numb : number ) {
    const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
    let str = ''
    do {
        str = letters[ numb % 26 ] + str
        numb = Math.floor( numb / 26 )
    } while ( numb )
    return str
}

string2number( str : string ) {
    let numb = 0
    for( let symb of str.split( '' ) ) {
        numb = numb * 26
        numb += symb.charCodeAt( 0 ) - 65
    }
    return numb
}

Размерность таблицы мы вычисляем на основе реестра формул, который берём из свойства formulas(). Возвращать оно должно json вида:


{
    "A1" : "12" ,
    "B1" : "=A1*2"
}

А сами формулы мы будем брать и строки адреса, вида #A1=12/B1=%3DA1*2:


@ $mol_mem
formulas( next? : { [ key : string ] : string } ) {
    const formulas : typeof next = {}

    let args = this.$.$mol_state_arg.dict()
    if( next ) args = this.$.$mol_state_arg.dict({ ... args , ... next })

    const ids = Object.keys( args ).filter( param => /^[A-Z]+\d+$/.test( param ) )

    for( let id of ids ) formulas[ id ] = args[ id ]

    return formulas
}

Как видно, свойство formulas() изменяемое, то есть мы можем через него как прочитать формулы для ячеек, так и записать обновление в адресную строку. Например, если выполнить: this.formulas({ 'B1' : '24' }), то в адресной строке мы увидим уже #A1=12/B1=24.


Адресная строка


Кроссплатформенный модуль $mol_state_arg позволяет нам работать с параметрами приложения как со словарём, но как правило удобнее получать и записывать конкретный параметр по имени. Например, позволим пользователю изменять название нашей таблицы, которое мы опять же будем сохранять в адресной строке:


title( next? : string ) {
    const title = this.$.$mol_state_arg.value( `title` , next )
    return title == undefined ? super.title() : title
}

Как можно заметить, если в адресной строке имя таблицы не задано, то будет взято имя заданное в родительском классе, который генерируется из calc.view.tree, который мы сейчас обновим, добавив в шапку вместо простого вывода заголовка, поле ввода-вывода заголовка:


head /
    <= Title_edit $mol_string
        value?val <=> title?val @ \Spreedsheet
    <= Tools -

head() — свойство из $mol_page, которое возвращает список того, что должно быть отрендерено внутри подкомпонента Head(). Это типичный паттерн в $mol — называть вложенный компонент и его содержимое одним и тем же словом, с той лишь разницей, что имя компонента пишется с большой буквы.


Tools() — панель инструментов из $mol_page, отображаемая с правой стороны шапки. Давайте сразу же заполним и её, поместив туда кнопку скачивания таблицы в виде CSV файла:


tools /
    <= Download $mol_link
        hint <= download_hint @ \Download
        file_name <= download_file         uri <= download_uri?val         click?event <=> download_generate?event null
        sub /
            <= Download_icon $mol_icon_load

$mol_link — компонент для формирования ссылок. Если ему указать file_name(), то по клику он предложит скачать файл по ссылке, сохранив его под заданным именем. Давайте же сразу сформируем это имя на основе имени таблицы:


download_file() {
    return `${ this.title() }.csv`
}

Локализация


Обратите внимание на символ собачки перед значением по умолчанию на английском языке:


download_hint @ \Download

Вставка этого символа — это всё, что вам необходимо, чтобы добавить вашему приложению поддержку локализации. В сгенерированном классе не будет строки "Download" — там будет лишь запрос за локализованным текстом:


/// download_hint @ \Download
download_hint() {
    return $mol_locale.text( "$mol_app_calc_download_hint" )
}

А сами английские тексты будут автоматически вынесены в отдельный файл -view.tree/calc.view.tree.locale=en.json:


{
    "$mol_app_calc_title": "Spreedsheet",
    "$mol_app_calc_download_hint": "Download"
}

Как видно, для текстов были сформированы уникальные человекопонятные ключи. Вы можете отдать этот файл переводчикам и переводы от них поместить в фалы вида *.locale=*.json. Например, добавим нашему компоненту переводы на русский язык в файл calc.locale=ru.json:


{
    "$mol_app_calc_title" : "Электронная таблица" ,
    "$mol_app_calc_download_hint" : "Скачать"
}

Теперь, если у вас в браузере выставлен русский язык в качестве основного, то при старте приложения, будет асинхронно подгружен бандл с русскоязычными текстами -/web.locale=ru.json. А пока идёт загрузка, компоненты, зависящие от переводов, будут автоматически показывать индикатор загрузки.


Заполняем ячейки


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


@ $mol_mem
head_cells() {
    return [ this.Col_head( '' ) , ... this.col_ids().map( colId => this.Col_head( colId ) ) ]
}

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


cells( row_id : number ) {
    return [ this.Row_head( row_id ) , ... this.col_ids().map( col_id => this.Cell({ row : row_id , col : col_id }) ) ]
}

Далее, вспоминаем, про свойства, которые мы провязывали для ячеек:


Cell!id $mol_app_calc_cell
    value <= result!id     selected?val <=> selected!id?val false

У ячейки это просто обычные свойства, а у нас они принимают ключ — идентификатор ячейки.


Введём свойство current() которое будет хранить идентификатор текущей ячейки:


current?val *
    row 1
    col \A

А в реализации selected() мы просто будем сравнивать ячейку по переданному идентификатору и по текущему:


@ $mol_mem_key
selected( id : { row : number , col : string } , next? : boolean ) {
    return this.Cell( this.current( next ? id : undefined ) ) === this.Cell( id )
}

Разумеется, если в selected() передано true, то будет установлен новый идентификатор в качестве текущего и сравнение ячеек тоже даст true.


Последний штрих — при выборе ячейки было бы не плохо переносить фокус с её самой на редактор значения:


@ $mol_mem
current( next? : { row : number , col : string } ) {
    new $mol_defer( ()=> this.Edit().focused( true ) )
    return next || super.current()
}

Тут мы с помощью $mol_defer ставим отложенную задачу перенести фокус на редактор всякий раз когда меняется идентификатор текущей ячейки. Отложенные задачи выполняются в том же фрейме анимации, а значит пользователь не увидит никакого мерцания от перефокусировки. Если бы мы перенесли фокус сразу, то подписались бы на состояние сфокусированности редактора и при перемещении фокуса — сбрасывался бы и идентификатор текущей ячейки, что нам, разумеется, не надо.


Клавиатурная навигация


Постоянно тыкать мышью в ячейки для перехода между ними не очень-то удобно. Стрелочками на клавиатуре было бы быстрее. Традиционно в электронных таблицах есть два режима: режим навигации и режим редактирования. Постоянно переключаться между ними тоже напрягает. Поэтому мы сделаем ход конём и совместим редактирование и навигацию. Фокус будет постоянно оставаться на панели редактирования ячейки, но при зажатой клавише Alt, нажатие стрелочек, будет изменять редактируемую ячейку на одну из соседних. Для подобных выкрутасов есть специальный компонент $mol_nav, который является компонентом-плагином.


В $mol есть 3 вида компонент:


  1. Обычные компоненты, которые создают dom-узел и контролируют его состояние.
  2. Призрачные компоненты, которые не создают dom-узлов, а используют dom-узел переданного им компонента, для добавления поведения/отображения.
  3. Компоненты-плагины, которые тоже не создают dom-узлов, а используют dom-узел компонента владельца для добавления поведения/отображения.

Добавляются плагины через свойство plugins(). Например, добавим клавиатурную навигацию нашему приложению:


plugins /
    <= Nav $mol_nav
        mod_alt true
        keys_x <= col_ids /
        keys_y <= row_ids /
        current_x?val <=> current_col?val \A
        current_y?val <=> current_row?val 1

Тут мы указали, что навигироваться мы будем по горизонтали и по вертикали, по идентификаторам столбцов и колонок, соответственно. Текущие координаты мы будем синхронизировать со свойствами current_col() и current_row(), которые мы провяжем с собственно current():


current_row( next? : number ) {
    return this.current( next === undefined ? undefined : { ... this.current() , row : next } ).row
}

current_col( next? : number ) {
    return this.current( next === undefined ? undefined : { ... this.current() , col : next } ).col
}

Всё, теперь нажатие Alt+Right, например, будет делать редактируемой ячейку справа от текущей, и так пока не упрётся в самую правую ячейку.


Копирование и вставка


Так как ячейки у нас являются ни чем иным, как нативными td dom-элементами, то браузер нам здорово помогает с копированием. Для этого достаточно зажать ctrl, выделить ячейки и скопировать их в буфер обмена. Текстовое представление содержимого буфера будет ни чем иным, как Tab Separated Values, который легко распарсить при вставке. Так что мы смело добавляем обработчик соответствующего события:


event *
    paste?event <=> paste?event null

И реализуем тривиальную логику:


paste( event? : ClipboardEvent ) {
    const table = event.clipboardData.getData( 'text/plain' ).trim().split( '\n' ).map( row => row.split( '\t' ) ) as string[][]
    if( table.length === 1 && table[0].length === 1 ) return

    const anchor = this.current()
    const row_start = anchor.row
    const col_start = this.string2number( anchor.col )
    const patch = {}

    for( let row in table ) {
        for( let col in table[ row ] ) {
            const id = `${ this.number2string( col_start + Number( col ) ) }${ row_start + Number( row ) }`
            patch[ id ] = table[ row ][ col ]
        }
    }

    this.formulas( patch )

    event.preventDefault()
}

Славно, что всё это работает не только в рамках нашего приложения — вы так же можете копипастить данные и между разными табличными процессорами, такими как Microsoft Excel или LibreOffice Calc.


Выгрузка файла


Частая хотелка — экспорт данных в файл. Кнопку мы уже добавили ранее. Осталось лишь реализовать формирование ссылки на экспорт. Ссылка должна быть data-uri вида data:text/csv;charset=utf-8,{'url-кодированный текст файла}. Содержимое CSV для совместимости с Microsoft Excel должно удовлетворять следующим требованиям:


  1. Каждое значение должно быть в кавычках.
  2. Кавычки экранируются посредством удвоения.

download_generate( event? : Event ) {
    const table : string[][] = []
    const dims = this.dimensions()

    for( let row = 1 ; row < dims.rows ; ++ row ) {
        const row_data = [] as any[]
        table.push( row_data )

        for( let col = 0 ; col < dims.cols ; ++ col ) {
            row_data[ col ] = String( this.result({ row , col : this.number2string( col ) }) )
        }
    }

    const content = table.map( row => row.map( val => `"${ val.replace( /"/g , '""' ) }"` ).join( ',' ) ).join( '\n' )

    this.download_uri( `data:text/csv;charset=utf-8,${ encodeURIComponent( content ) }` )

    $mol_defer.run()
}

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


Формулы


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


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


Реализовывать парсинг и анализ выражений — довольно сложная задача, а вечеринке уже мерещится ДедЛайн, так что мы не долго думая воспользуемся всей мощью JavaScript и позволим пользователю писать любые JS выражения. Но, чтобы он случайно не отстрелил ногу ни себе, ни кому-то ещё, будем исполнять его выражение в песочнице $mol_func_sandbox, которая ограничит мощь JavaScript до разрешённых нами возможностей:


@ $mol_mem
sandbox() {
    return new $mol_func_sandbox( Math , {
        'formula' : this.formula.bind( this ) ,
        'result' : this.result.bind( this ) ,
    } )
}

Как видите, мы разрешили пользователю использовать математические функции и константы, а также предоставили пару функций: для получения формулы ячейки и вычисленного значения ячейки по её идентификатору.


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


@ $mol_mem_key
func( id : { row : number , col : string } ) {
    const formula = this.formula( id )
    if( formula[0] !== '=' ) return ()=> formula

    const code = 'return ' + formula.slice( 1 )
    .replace( /@([A-Z]+)([0-9]+)\b/g , 'formula({ row : $2 , col : "$1" })' )
    .replace( /\b([A-Z]+)([0-9]+)\b/g , 'result({ row : $2 , col : "$1" })' )

    return this.sandbox().eval( code )
}

Заставлять пользователя писать вызов функции result вручную — слишком жестоко. Поэтому мы слегка изменяем введённую формулу, находя комбинации символов, похожие на кодовые имена ячеек вида AB34, и заменяя их на вызовы result. Дополнительно, вместо значения, можно будет получить формулу из ячейки, приписав спереди собачку: @AB34. Создание таких функций — не бесплатно, так что если в ячейке у нас просто текст, а не выражение, то мы так его и возвращаем безо всяких песочниц.


Осталось дело за малым — реализовать свойство result() с дополнительной постобработкой для гибкости:


@ $mol_mem_key
result( id : { row : number , col : string } ) {
    const res = this.func( id ).call()
    if( res === undefined ) return ''
    if( res === '' ) return ''
    if( isNaN( res ) ) return res
    return Number( res )
}

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


Финальный аккорд


На этом основная программа нашей вечеринки подходит к концу. Полный код приложения $mol_app_calc доступен на ГитХабе. Но прошу вас не спешить расходиться. Давайте каждый возьмёт по электронной таблице в свои руки и попробует сделать с ней что-нибудь эдакое. Вместе у нас может получиться интересная галерея примеров её использования. Итак...


Оценка дальнейшего развития $mol_app_calc


Кредитный калькулятор


a*x**2 + b*x + c = 0

Комментарии (28)


  1. mayorovp
    27.09.2017 11:13

    Если взять слишком много строк (например, 100000) — то после каждого изменения перестают работать скрол и нажиматься кнопки на клавиатуре.

    Интересно, можно ли это считать багом?


    1. vintage Автор
      27.09.2017 12:17
      +1

      Конечно, это из-за того, что налету генерировался огромный CSV файл. Сделал, чтобы ссылка генерировалась лишь по клику на кнопку скачивания, а не сразу при изменении формул.


  1. mayorovp
    27.09.2017 11:20
    +1

    А вот это уже баг.


    Если написать вот такие формулы:


    A1 = B1+1
    B1 = A1+1


    то в соответствующих ячейках будет сообщение об ошибке.


    Теперь меняем B1 на что-нибудь нормальное… и сообщение об ошибке из A1 не пропадает!


    Более того, кнопка скачивания тоже превращается в сообщение об ошибке (но нажимается).


    1. vintage Автор
      27.09.2017 12:58
      +2

      Оказывается $mol_atom неправильно восстанавливался после циклической зависимости. Уже поправлено.


  1. lega
    27.09.2017 14:27

    Если в таблице высота строк одинаковая, то можно такой скролл запилить: jsfiddle.net/lega911/mchdL1ca


    1. vintage Автор
      27.09.2017 14:38

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


      1. RubaXa
        27.09.2017 14:51
        +1

        А почему сейчас при скролинге, названия столбцов и боковины пропадают?


        1. vintage Автор
          27.09.2017 15:21

          Потому что используется аппаратная прокрутка (не отключаемая на мобилках), а перемещение плавающих блоков происходит асинхронно по событию скролла. Если их не прятать, то они будут неприятно "прыгать" при прокрутке.


          1. RubaXa
            27.09.2017 15:25

            эээ, не понял, причем тут срол? Это же явно «особенность/косяк» реализации $mol_float, обычно sticky-блоки не исчезают при прокрутке, а корректно перемещаются под её воздействием.


            1. vintage Автор
              27.09.2017 16:40

              Как бы вы реализовали $mol_float?


              1. RubaXa
                27.09.2017 16:46

                По возможности использовал бы position: sticky, иначе scroll & resize + getBoundingClientRect и всё это приправленно throttle, как обычно короче.


                1. vintage Автор
                  27.09.2017 16:57

                  По возможности использовал бы position: sticky

                  Если бы он ещё везде работал для ячеек таблиц.


                  иначе scroll & resize + getBoundingClientRect и всё это приправленно throttle

                  $mol_float так и работает. Но если его не прятать при скроллинге — он будет прыгать, ибо событие скролла срабатывает асинхронно прокрутке.


                  1. RubaXa
                    27.09.2017 17:00

                    Странно, у всех не прыгает, а у вас прыгаете, тут либо задержка большая, либо код тормозит. Обычный throttling на 16-30ms и никакого дергания.


                    1. vintage Автор
                      27.09.2017 17:09

                      У нас тоже не прыгает, если скроллинг отрабатывает не аппаратно. Например, если тягать за скроллбар в десктопном хроме.


              1. mayorovp
                27.09.2017 17:04

                Я бы не прятал заголовки строк если прокрутка вертикальная.


                1. vintage Автор
                  27.09.2017 17:30

                  Хорошая идея. Сделал, чтобы плавающий блок прятался только, если направление "плавания" совпадает с направлением прокрутки.


      1. lega
        27.09.2017 15:00

        В вашей реализации фокус уплывает
        Да я в курсе, просто выложил показать идею скрола, 30 строк кода, а не готовый excel.


  1. RubaXa
    27.09.2017 14:46
    +5

    Первая годная статья по $mol, к концу наконце-то смог въехать в tree (чуть-чуть), но code style как был «кровь, кишки, расчленёнка», так и остался.


    Но остался вопрос, позволяет-ли $mol писать компоненты, которые будут встриваться в уже готовый проект. Например хочу «форму Входа» и кнопку «Наверх» (с произвольным обработчиком onClick)?


    1. PaulMaly
      27.09.2017 14:55
      +5

      Синтаксис и кодинг стайл — самая главная проблема mol, из-за которой его даже пробовать не хочется(((( Помнится jquery всегда любили не столько за кросс-браузерность, сколько за апи. Если в мире останутся только mol и jquery, я буду писать долго и дорого, но на последнем.


    1. vintage Автор
      27.09.2017 16:30
      +2

      Конечно позволяет. Например, хотите вы выводить markdown, но не хотите заморачиваться с его парсингом. Для этого можно воспользоваться компонентом $mol_text. Как это сделать...


      Собираем независимый бандл с этим компонентом:


      npm start mol/text

      В директории mol/text/- появятся следующие файлы:


      • web.js — скрипты
      • web.d.ts — определения типов для typescript
      • web.css — стили

      Подключаем их к странице и рендерим нужный текст:


      <link rel="stylesheet" href="text/web.css" />
      <script src="text/web.js"></script>
      <body>
      <script>
      
          // Создаём реактивную переменную
          var text = new $mol_atom( 'text' , ()=> 'Текущее время *ещё не установлено*...' )
      
          // Периодически меняем текст
          setInterval( ()=> text.push( `Текущее время: **${ new Date().toLocaleString() }**` ) , 1000 )
      
          // Создаём компонент
          var view = new $mol_text
      
          // Провязываем с нашей реактивной переменной
          view.text = ()=> text.get()
      
          // Указываем рендериться в body
          view.dom_node = ()=> document.body
      
          // Инициируем автоматический рендеринг
          view.dom_tree()
      
      </script>

      https://plnkr.co/edit/W15eZoL4aoZptX7oHyyO?p=preview


      1. RubaXa
        27.09.2017 16:36

        Так, а если таких компонентов N-штук (каждый в своём бандле), как мне их собрать, чтобы они включали в себя только свой код, а всё остальное было в $mol.bundle.js, а потом всё это добро можно было грузить асинхронно, например через SystemJS?


        P.S. Можете сделать пример именно с кнопкой "Наверх" с определенным onClick/alter?


        1. vintage Автор
          27.09.2017 16:52
          +1

          Так, а если таких компонентов N-штук (каждый в своём бандле), как мне их собрать, чтобы они включали в себя только свой код, а всё остальное было в $mol.bundle.js, а потом всё это добро можно было грузить асинхронно, например через SystemJS?

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


          Можете сделать пример именно с кнопкой "Наверх" с определенным onClick/alter?

          Тут всё то же самое:


          // Создаём компонент
          var view = new $mol_button_major
          
          // Клики обрабатываем по своему
          view.event_click = event => document.body.scrollTop = 0
          
          // Задаём название
          view.title = ()=> 'Наверх'
          
          // Рендерим в body
          document.body.appendChild( view.dom_tree() )

          https://plnkr.co/edit/2lRttrdS2tXMFq2uZqfq?p=preview


          1. RubaXa
            27.09.2017 16:56
            +1

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

            Я говорю про задачу, когда нужно сделать готовые виджеты, который просто подключаются к странице я не хочу, чтобы каждый такой виджет включал в себя 30KB core-кода. На том же React/jQuery мне не нужно включать их сборку, я просто оставляю эти зависимости открытыми.


            ps спасибо за пример, теперь понятно.


            1. vintage Автор
              27.09.2017 17:44

              я не хочу, чтобы каждый такой виджет включал в себя 30KB core-кода

              Общего кода там куда меньше. Ну, например, минифицированный зипованный бандл mol/button весит меньше 8кб. В любом случае, у $mol нет как такового ядра. Лучшим решением в данном случае на мой взгляд был бы онлайн сборщик бандла, как тут: http://jqueryui.com/download/


  1. punkkk
    27.09.2017 16:54

    ";" в качестве разделителя

    Это же зависит от локали офиса. csv из расшифровки подразумевает деление запятой, ';' только в отечественном офисе.


    1. vintage Автор
      27.09.2017 17:04

      Вы чертовски правы. Эксель понимает и так и так. Жаль, он не понимает TSV. Поправил на запятые.


  1. darkdaskin
    27.09.2017 18:18
    +1

    Баг: если быстро вводить текст и нажимать Alt+v (или щёлкать на следующую ячейку), то содержимое предыдущей ячейки дописывается в начало текущей. А иногда нажатия клавиш просто игнорируются.


    1. vintage Автор
      27.09.2017 20:51

      Тут дело в том, что $mol_string не сразу записывает значение, а делает debounce пользовательского ввода. Сделано это по 2 причинам:


      1. Чтобы ввод не затыкался, если будет тяжёлый рендеринг на каждое нажатие клавиши. Переполнение клавиатурного буфера — неприятная штука.
      2. Чтобы запросы на сервер не слались, пока пользователь не закончит ввод. Дебаунсить сами серверные запросы не правильно, ибо на клики, например, нужно реагировать сразу.

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


      В коде это выглядит так:


      Edit_current() {
          return this.Edit( this.current() )
      }