HabHub icon indicating copy to clipboard operation
HabHub copied to clipboard

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

Open nin-jin opened this issue 7 years ago • 3 comments

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

nin-jin avatar Sep 25 '17 17:09 nin-jin

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

kcant avatar Nov 03 '19 01:11 kcant

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

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

image

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

kcant avatar Nov 03 '19 01:11 kcant

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

nin-jin avatar Nov 03 '19 06:11 nin-jin