Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

$mol_app_calc: вечеринка электронных таблиц #10

Open
nin-jin opened this issue Sep 25, 2017 · 3 comments
Open

Comments

@nin-jin
Copy link
Owner

nin-jin commented Sep 25, 2017

https://page.hyoo.ru/#!=6vhts9_pxb81u

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

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

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

А дальше я расскажу, как сотворить такое же за вечер используя фреймворк $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 очень легко читать, но нужно немного привыкнуть и не бросить всё раньше времени 😜. Мой мозг переваривал и негодовал около недели, а потом приходит просветление и понимаешь как сильно этот фреймворк упрощает процесс разработки. (c) Виталий Макеев

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

У каждого компонента есть свойство 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

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

ax**2 + bx + c = 0

@kcant
Copy link

kcant commented Nov 3, 2019

image
выход за границы и наложение получается

@kcant
Copy link

kcant commented Nov 3, 2019

image
выход за границы и наложение получается

после нажатия кнопки Comment все стало нормально отображаться...

image

не успел проверить в режиме инкогнито с отключенными расширениями - возможно в Chrome расширения что-то портят

@nin-jin
Copy link
Owner Author

nin-jin commented Nov 3, 2019

Это же гитхаб, его стили мне не подконтрольны.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants