Библиотека сайта rus-linux.net
Blockcode: Инструментальное средство визуального программирования (продолжение)
Оригинал: Blockcode: A visual programming toolkit
Автор: Dethe Elza
Дата публикации: 17 December 2015
Перевод: Н.Ромоданов
Дата перевода: 2016 г.
Начало статьи смотрите здесь
Пройдемся по коду
В рамках данного проекта я пытался следовать некоторым общепринятым соглашениям и лучшим примерам реализации. Каждый файл JavaScript обернут в функцию с тем, чтобы избежать влияния локальных переменных на в глобальную среду. Если нужно получить значения переменных в других файла, то для каждого файла определяется единственная глобальная переменная, связанная с именем файла, через которую можно получить функциям этого файла. Переменная указывается ближе к концу файла после всех обработчиков событий, установленных в этом файле, так что вы всегда можете взглянуть в конец файла с тем, чтобы увидеть, какие события он обрабатывает и какие функции она предоставляет.
Стиль кода является процедурным, а не объектно-ориентированным или функциональным. Мы могли бы сделать то же самое в любой из этих парадигм, но в подобном случае потребуется больше настроечного кода и больше оберток вокруг того, что уже существует для DOM. Недавние результаты, полученные в Custom Elements, облегчают работу с DOM при использовании объектно-ориентированного подхода; а в Functional JavaScript есть много интересных примеров функциональный решений, но нужны доработки, поэтому проще использовать процедурный подход.
В этом проекте есть восемь исходных файлов, но в файлах index.html
и blocks.css
содержатся типовые структура и стиль приложения и, поэтому они рассматриваться не будут. Еще два файлов JavaScript, которые не будет подробно рассматриваться: файл util.js
, в котором находятся некоторые хелперы и который служит связующим звеном между различными реализациями браузеров (наподобие библиотеки JQuery, но менее 50 строк кода) и файл file.js
, в котором содержится аналогичная утилита, предназначенная для загрузки и сохранения файлов, а также сериализации скриптов.
Оставшиеся файлы следующие:
block.js
- это абстрактное представление блочного языка.drag.js
реализует ключевые возможности языка: позволяет пользователю перетаскивать блоки из списка доступных блоков (из "меню") так, чтобы собрать из них программу ("скрипт").menu.js
содержит немного вспомогательного кода, а также отвечает за фактическую работу программы пользователя.turtle.js
определяет специфику нашего блочного языка (черепашья графика) и инициализирует работу специальных блоков. Для того, чтобы создать другой блочный язык, этот файл следует заменить.
blocks.js
Каждый блок состоит из нескольких элементов HTML, использует стили CSS и несколько обработчиков событий JavaScript, позволяющих мышкой перетаскивать блоки и изменять аргумент ввода. Файл blocks.js позволяет создавать и управлять группами таких элементов как единым объектом. Когда в меню блока добавляется тип блока, то он ассоциативно связывается с функцией JavaScript, которая реализует язык, поэтому для каждого блока, добавленного в скрипт, можно найти ассоциированную с ним функцию и вызвать ее при запуске скрипта.
Рис.2 Пример блока
Пояснение: Right [] degrees - Вправо на [] градусов
В блоках есть две дополнительные особенности структуры. Они могут иметь один числовой параметр (со значением, используемым по умолчанию), и они могут быть контейнером для других блоков. Эти жесткие ограничения для работы, но они будет смягчены в более крупной системе. В Waterbear есть также блоки с выражениями, в которые могут быть переданы параметры; поддерживается использование несколько параметров разных типов. Здесь, условиях жестких ограничений, мы посмотрим, что можно сделать только с одним типом параметра.
<!-- Структура облока HTML --> <div class="block" draggable="true" data-name="Right"> Right <input type="number" value="5"> degrees </div>
Важно отметить, что нет никаких реальных различий между блоками, находящимися в меню, и блоками, размещенными в скрипте. При перетаскивании они обрабатываются немного по-разному в зависимости от того, откуда мы их перетаскиваем, и когда мы запускаем скрипт, он рассматривает только блоки в области скрипта, но принципиально это одни и те же структуры, что означает, что мы можем при перетаскивании блоков из меню в скрипт просто их клонировать.
Функция createBlock(name, value, contents)
возвращает блок как элемент DOM, заполненный всеми внутренними элементами и готовый для вставки в документ. Ее можно использовать для создания блоков меню или для восстановления блоков скрипта, сохраненных в файлах или в локальном хранилище localStorage
. Хотя этот механизм достаточно гибкий, он был создан специально для «языка» Blockcode и с учетом его особенностей, поэтому если имеется некоторое значение, то предполагается, что значение представляет собой числовой аргумент, и создает вход для типа "number" ("номер"). Поскольку это ограничение языка Blockcode, то все замечательно, но если бы мы должны были расширить блоки для поддержки других типов аргументов, или более одного аргумента, то код потребовалось бы изменить.
function createBlock(name, value, contents){ var item = elem('div', {'class': 'block', draggable: true, 'data-name': name}, [name] ); if (value !== undefined && value !== null){ item.appendChild(elem('input', {type: 'number', value: value})); } if (Array.isArray(contents)){ item.appendChild( elem('div', {'class': 'container'}, contents.map(function(block){ return createBlock.apply(null, block); }))); }else if (typeof contents === 'string'){ // Спецификатор добавления единиц (градусов и т.п.) item.appendChild(document.createTextNode(' ' + contents)); } return item; }
У нас есть несколько утилит, позволяющих обрабатывать блоков как элементы DOM:
blockContents(block)
извлекает дочерние блоки из контейнерного блока. Он всегда возвращает список, если он вызывается для контейнерного блока, и всегда возвращает null в случае простого блокаblockValue(block)
возвращает числовое значение входного аргумента блока в случае, если в блоке есть поле ввода типа number, или null, если в блоке нет поля ввода.blockScript(block)
возвращает структуру, пригодную для сериализации в JSON так, чтобы сохранить блоки в таком виде, из которого они могут быть легко восстановленыrunBlocks(blocks)
представляет собой обработчик для запуска массива блоков, отправляющий каждому блока событие "run" ("запустить")
function blockContents(block){ var container = block.querySelector('.container'); return container ? [].slice.call(container.children) : null; } function blockValue(block){ var input = block.querySelector('input'); return input ? Number(input.value) : null; } function blockUnits(block){ if (block.children.length > 1 && block.lastChild.nodeType === Node.TEXT_NODE &amo;& block.lastChild.textContent){ return block.lastChild.textContent.slice(1); } } function blockScript(block){ var script = [block.dataset.name]; var value = blockValue(block); if (value !== null){ script.push(blockValue(block)); } var contents = blockContents(block); var units = blockUnits(block); if (contents){script.push(contents.map(blockScript));} if (units){script.push(units);} return script.filter(function(notNull){ return notNull !== null; }); } function runBlocks(blocks){ blocks.forEach(function(block){ trigger('run', block); }); }
drag.js
Назначение файла drag.js
- превращать статические блоки HTML в динамический язык программирования путем осуществления взаимодействия между областью меню и областью скрипта. Пользователь строит свою программу перетаскивая блоки из меню в скрипт, а система запускает блоки в области скрипта.
Мы используем свойство drag-and-drop (перетащить и отпустить) языка HTML5; в этом файле определены конкретные обработчики событий JavaScript, которые для этого требуются. Дополнительную информацию об использовании свойства свойство drag-and-drop языка HTML5 смотрите в статье Эрика Бидлемена. Хотя приятно иметь встроенную поддержку этого свойства, нов работе этого свойства есть некоторые странности, а также некоторые довольно серьезные ограничения, из-за которых на момент написания данной статьи это свойство было реализовано не во всех мобильных браузерах.
Определим некоторые переменные в начале файла. Когда мы выполняем перетаскивание, нам на разных этапах перетаскивания потребуется на них ссылаться.
var dragTarget = null; // Блок, который мы перетягиваем var dragType = null; // Мы перетягиваем его из меню или из скрипта? var scriptBlocks = []; // Блоки в скрипте, отсортированные по позициям
В зависимости от того, откуда начинается перетягивание и где оно завершается, операция drop будет выполняться по-разному:
- Если перетаскивание осуществляется из скрипта в меню, то
dragTarget
удаляется (из скрипта удаляется блок). - Если перетаскивание осуществляется из одного скрипта в другой, то
dragTarget
перемещается (существующий блок скрипта перемещается). - Если перетаскивание осуществляется из меню в скрипт, то
dragTarget
копируется (в скрипт вставляется новый блок). - Если перетаскивание происходит из меню в меню, то ничего не делается.
В процессе работы обработчика dragStart(evt)
мы отслеживаем, копируется ли блок из меню или перемещается из скрипта или в скрипт. Мы также получаем список всех блоков в скрипте, которые не перемещаются, с тем, чтобы позднее им воспользоваться. Вызов evt.dataTransfer.setData
используется при перетаскивании объекта между браузером и другими приложениями (или рабочим столом), чем мы не пользуемся, но мы должны его вызвать в любом случае для того, чтобы избежать ошибок.
function dragStart(evt){ if (!matches(evt.target, '.block')) return; if (matches(evt.target, '.menu .block')){ dragType = 'menu'; }else{ dragType = 'script'; } evt.target.classList.add('dragging'); dragTarget = evt.target; scriptBlocks = [].slice.call( document.querySelectorAll('.script .block:not(/.dragging)')); // При перетаскивании в Firefox мы должны стедать такую настройку // даже если мы этим не пользуемся evt.dataTransfer.setData('text/html', evt.target.outerHTML); if (matches(evt.target, '.menu .block')){ evt.dataTransfer.effectAllowed = 'copy'; }else{ evt.dataTransfer.effectAllowed = 'move'; } }
В процессе перетаскивания события dragenter
, dragover
и dragout
дают нам возможность добавить визуальные подсказки, выделяя допустимые конечные точки перетаскивания и т.д. Из них мы пользуемся только dragover.
function dragOver(evt){ if (!matches(evt.target, '.menu, .menu *, .script, .script *, .content')) { return; } // Необходимо. Позволяет нам поместить объект в конечную точку перетаскивания. if (evt.preventDefault) { evt.preventDefault(); } if (dragType === 'menu'){ // See the section on the DataTransfer object. evt.dataTransfer.dropEffect = 'copy'; }else{ evt.dataTransfer.dropEffect = 'move'; } return false; }
Когда мы отпускаем кнопку мыши, то возникает событие drop
. Вот здесь и происходит вся магия. Мы должны проверить, откуда произошло перемещение (вернувшись обратно в dragStart
) и куда. Затем мы либо копируем блок, либо перемещаем блок, либо его удаляем, если это необходимо. Мы отключили некоторые пользовательские события, использующие trigger()
(определен в util.js
), поэтому мы можем обновить скрипт, когда он изменится.
function drop(evt){ if (!matches(evt.target, '.menu, .menu *, .script, .script *')) return; var dropTarget = closest( evt.target, '.script .container, .script .block, .menu, .script'); var dropType = 'script'; if (matches(dropTarget, '.menu')){ dropType = 'menu'; } // Отключаем в браузере перенаправление. if (evt.stopPropagation) { evt.stopPropagation(); } if (dragType === 'script' && dropType === 'menu'){ trigger('blockRemoved', dragTarget.parentElement, dragTarget); dragTarget.parentElement.removeChild(dragTarget); }else if (dragType ==='script' && dropType === 'script'){ if (matches(dropTarget, '.block')){ dropTarget.parentElement.insertBefore(/ dragTarget, dropTarget.nextSibling); }else{ dropTarget.insertBefore(dragTarget, dropTarget.firstChildElement); } trigger('blockMoved', dropTarget, dragTarget); }else if (dragType === 'menu' && dropType === 'script'){ var newNode = dragTarget.cloneNode(true); newNode.classList.remove('dragging'); if (matches(dropTarget, '.block')){ dropTarget.parentElement.insertBefore(/ newNode, dropTarget.nextSibling); }else{ dropTarget.insertBefore(newNode, dropTarget.firstChildElement); } trigger('blockAdded', dropTarget, newNode); } }
dragEnd(evt)
вызывается, когда мы перестаем нажимать кнопку мыши, но после того, как мы обработаем событие drop
. Именно здесь мы можем выполнять очистку, удалять элементы из классов и перезагружать все настройки перед тем, будем выполнять следующее перетаскивание.
function _findAndRemoveClass(klass){ var elem = document.querySelector('.' + klass); if (elem){ elem.classList.remove(klass); } } function dragEnd(evt){ _findAndRemoveClass('dragging'); _findAndRemoveClass('over'); _findAndRemoveClass('next'); }
menu.js
Файл menu.js
это то место, где блоки связываются с функциями, вызываемыми в тех случаях, когда блоки исполняются; в этом файле находится код, исполняемый в тот момент, когда пользователь осуществляет сборку скрипта. Каждый раз, когда скрипт изменяется, он автоматически перезапускается.
"Меню" в данном контексте это не выпадающее меню (или всплывающее окно) как это обычно в большинстве приложений; это список блоков, из которого вы можете выбрать блоки для вашего скрипта. В этом файле задается, что должно быть запущено, и запускается или останавливается меню, когда это нужно (поэтому он не является частью самого черепашьего языка). Это файл, куда помещается все, что не может быть размещено где-нибудь в другом месте.
Полезно иметь отдельный файл, в котором можно собирать функции, создаваемые по ходу дела, особенно когда архитектура находится в стадии разработки. Моя теория содержать дом в чистоте требует, чтобы были специально отведенные места для вещей, нарушающих порядок, и также это относится к построению архитектуры программ. Один файл или модуль становится общим для всего того, для чего нельзя определить четкого места, куда это можно еще поместить. По мере роста этого файла важно следить за возникающими взаимозависимостями функций: несколько связанных друг с другом функций можно выделить в отдельный модуль (или объединить в более общую функцию). Вы не должны допускать, чтобы такой файл рос до бесконечности, а только был временным местом размещения функций до тех пор, пока вы не выяснится, как правильно организовать код.
Мы храним ссылки на menu
и script
потому, что мы пользуемся ими часто; нет никакого смысла снова и снова просматривать для этого DOM. Мы будем также использовать реестр скриптов scriptRegistry
, в котором мы будем хранить скрипты блоков меню. Мы пользуемся очень простым отображением «имя – скрипт», в котором не поддерживается именование одним именем нескольких блоков меню и переименовании блоков. Более сложная среда работы со скриптами потребовала бы что-то более надежное.
Мы используем scriptDirty
для того, чтобы отслеживать, был ли скрипт изменен с момента, когда он был последний раз запущен и не пытаться запускать его постоянно.
var menu = document.querySelector('.menu'); var script = document.querySelector('.script'); var scriptRegistry = {}; var scriptDirty = false;
Когда мы хотим уведомить систему о том, чтобы во время следующей работы обработчика кадров запустить скрипт, мы вызываем функцию runSoon()
, которая устанавливает флаг scriptDirty
в true. Система вызывает функцию run()
для каждого кадра, но сразу же возвращает управление в случае, если не установлен флаг scriptDirty
. Когда флаг scriptDirty
установлен, то запускаются все блоки скриптов, а также триггеры событий, которые позволяют обработчику конкретного языка выполнять любые задачи, которые необходимо выполнить до запуска скрипта и после того, как скрипт будет запущен. В результате блоки, как инструментальное средство, изолируются от черепашьего языка, и их можно будет использовать повторно (или переключать язык, в зависимости, как вы на это смотрите).
В процессе рамках скрипта мы проходим по каждому блоку, вызывая для него функцию runEach(evt)
, которая задает класс блока, а затем находим и выполняем связанную с этим блоком функцию. Если замедлить ход событий, то появится возможность увидеть, как подсвечивается каждый блок по мере того, как выполняется связанный с ним код.
Метод requestAnimationFrame
, представленный ниже, используется браузером для анимации. Он получает функцию, которая будет вызываться в следующем кадре, перерисовываемом браузером (со скоростью 60 кадров в секунду) после того, как будет сделан вызов. Сколько кадров мы, на самом деле, получим, зависит от того, насколько быстро мы сможем выполнить работу во время этого вызова.
function runSoon(){ scriptDirty = true; } function run(){ if (scriptDirty){ scriptDirty = false; Block.trigger('beforeRun', script); var blocks = [].slice.call( document.querySelectorAll('.script > .block')); Block.run(blocks); Block.trigger('afterRun', script); }else{ Block.trigger('everyFrame', script); } requestAnimationFrame(run); } requestAnimationFrame(run); function runEach(evt){ var elem = evt.target; if (!matches(elem, '.script .block')) return; if (elem.dataset.name === 'Define block') return; elem.classList.add('running'); scriptRegistry[elem.dataset.name](elem); elem.classList.remove('running'); }
Мы добавляем блоки в меню при помощи menuItem(name, fn, value, contents)
, который берет обычный блок, связывает его с функцией и помещает его в колонку меню.
function menuItem(name, fn, value, units){ var item = Block.create(name, value, units); scriptRegistry[name] = fn; menu.appendChild(item); return item; }
Мы определяем метод repeat(block)
здесь, вне черепашьего языка, поскольку этот метод обычно полезен и в других языках. Если бы у нас были блоки для проверки условий и чтения и записи переменных, то их также следовало бы помещать здесь или в отдельный независимый от языка модуль, но сейчас у нас есть только один из таких блоков общего назначения.
function repeat(block){ var count = Block.value(block); var children = Block.contents(block); for (var i = 0; i < count; i++){ Block.run(children); } } menuItem('Repeat', repeat, 10, []);
turtle.js
Файл turtle.js
является реализация черепашьего блочного языка. В нем нет никаких функций, которые используются в остальных частях кода, так что от него ничто не может зависеть. Таким образом, мы можем поменяет его на другой файл с тем, чтобы создать новый блочный язык и быть уверенным в том, что в ядре нашего приложения ничего не сломается.
Рис.3. Пример работающего кода Turtle
Пояснение: Repeat [] - Повторить [] раз; Right [] degrees - Вправо на [] градусов;
Forward [] steps - ,Вперед на [] шагов; Hide turtile - Скрыть черепаху
Черепашье программирование является стилем графического программирования, первоначально популяризированным языком Logo, в котором у вас есть воображаемая черепаха, перемещающая по экрану перо. Вы можете сказать черепахе, чтобы она подняла перо (прекратила рисовать, но все-таки двигалась), опустила перо (оставляя линию везде, где двигается), переместиться вперед на определенное число шагов или повернуться на некоторое количество градусов. Можно только с помощью этих команд и циклов создавать удивительно сложные образы.
В этой версии черепашьей графики у нас есть несколько дополнительных блоков. Технически нам одновременно не нужно иметь операции turn right
(повернуть направо) и turn left
(повернуть налево), поскольку у вас может быть только одна из них, а другую вы можете получить с помощью отрицательных чисел. Точно так же операция move back
(двигаться назад) может быть получена с помощью операции move forward
(двигаться вперед) и отрицательных чисел. В нашем случае для обеспечения баланса реализованы все варианты.
Изображение, приведенное выше, было создано с помощью двух вложенных друг в друга циклов и добавление в каждый цикл операций move forward и turn right
, а затем подбора параметров в интерактивном режиме до тех пор, пока не понравится получившееся изображение.
var PIXEL_RATIO = window.devicePixelRatio || 1; var canvasPlaceholder = document.querySelector('.canvas-placeholder'); var canvas = document.querySelector('.canvas'); var script = document.querySelector('.script'); var ctx = canvas.getContext('2d'); var cos = Math.cos, sin = Math.sin, sqrt = Math.sqrt, PI = Math.PI; var DEGREE = PI / 180; var WIDTH, HEIGHT, position, direction, visible, pen, color;
Функция сброса reset()
возвращает значения всех переменных в состояние, определенное по умолчанию. Если бы мы должны были поддерживать движение нескольких черепах, то эти переменные должны быть инкапсулированы в объект. У нас также есть утилита deg2rad(/deg)
, т.к. мы в пользовательском интерфейсе работаем в градусах, а рисуем в радианах. И, наконец, метод drawTurtle()
рисует саму черепаху. По умолчанию черепахой является просто треугольник, но вы можете изменить и получить более "черепахоподобную" черепаху.
Обратите внимание, что drawTurtle
использует те же самые примитивные операции, которые мы определяем для реализации черепашьего рисования. Иногда вам не нужно повторно использовать код на различных слоях абстракции, но когда смысл понятен, эта возможность могла бы быть большой победой над размером кода и производительностью.
function reset(){ recenter(); direction = deg2rad(90); // поворачиваемся "вверх" visible = true; pen = true; // когда pen равно true, мы рисуем, в противном случаем двигаемся, но не рисуем color = 'black'; } function deg2rad(degrees){ return DEGREE * degrees; } function drawTurtle(){ var userPen = pen; // сохраняем состояние пера pen if (visible){ penUp(); _moveForward(5); penDown(); _turn(-150); _moveForward(12); _turn(-120); _moveForward(12); _turn(-120); _moveForward(12); _turn(30); penUp(); _moveForward(-5); if (userPen){ penDown(); // восстанавливаем состояние пера pen } } }
У нас есть специальный блок, который рисует круг с заданным радиусом кривизны в текущей позиции курсора мыши. У нас есть специальная операция drawCircle
, поскольку вы можете, конечно, нарисовать круг, повторяя 360 раз операцию MOVE 1 RIGHT 1 360
, но размер круга таким образом контролировать очень трудно.
function drawCircle(radius){ // Пакет Math взят из http://www.mathopenref.com/polygonradius.html var userPen = pen; // сохраняем состояние пера pen if (visible){ penUp(); _moveForward(-radius); penDown(); _turn(-90); var steps = Math.min(Math.max(6, Math.floor(radius / 2)), 360); var theta = 360 / steps; var side = radius * 2 * Math.sin(Math.PI / steps); _moveForward(side / 2); for (var i = 1; i < steps; i++){ _turn(theta); _moveForward(side); } _turn(theta); _moveForward(side / 2); _turn(90); penUp(); _moveForward(radius); penDown(); if (userPen){ penDown(); // восстанавливаем состояние пера pen } } }
Наш главный примитив - moveForward
(двигаться вперед), который должен обрабатывать некоторую элементарную тригонометрию и проверить, поднято ли перо или опущено вниз.
function _moveForward(distance){ var start = position; position = { x: cos(direction) * distance * PIXEL_RATIO + start.x, y: -sin(direction) * distance * PIXEL_RATIO + start.y }; if (pen){ ctx.lineStyle = color; ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineTo(position.x, position.y); ctx.stroke(); } }
Большую часть остальных черепашьих команд можно легко определить с помощью того, что мы построили выше.
function penUp(){ pen = false; } function penDown(){ pen = true; } function hideTurtle(){ visible = false; } function showTurtle(){ visible = true; } function forward(block){ _moveForward(Block.value(block)); } function back(block){ _moveForward(-Block.value(block)); } function circle(block){ drawCircle(Block.value(block)); } function _turn(degrees){ direction += deg2rad(degrees); } function left(block){ _turn(Block.value(block)); } function right(block){ _turn(-Block.value(block)); } function recenter(){ position = {x: WIDTH/2, y: HEIGHT/2}; }
Когда мы хотим начать все заново, то с помощью функции clear мы восстанавливаем все в то состояние, с которого мы начали.
function clear(){ ctx.save(); ctx.fillStyle = 'white'; ctx.fillRect(0,0,WIDTH,HEIGHT); ctx.restore(); reset(); ctx.moveTo(position.x, position.y); }
Когда скрипт загружается и выполняется первый раз, мы используем наши операции reset
и clear
для того, чтобы всен инициализировать и нарисовать черепаху.
onResize(); clear(); drawTurtle();
Теперь мы можем использовать функции, приведенные выше с функцией Menu.item
из menu.js
с тем, чтобы создать блоки, из которых пользователь будет создавать скрипты. Они будут перетягиваться мышкой при создании пользовательских программ.
Menu.item('Left', left, 5, 'degrees'); Menu.item('Right', right, 5, 'degrees'); Menu.item('Forward', forward, 10, 'steps'); Menu.item('Back', back, 10, 'steps'); Menu.item('Circle', circle, 20, 'radius'); Menu.item('Pen up', penUp); Menu.item('Pen down', penDown); Menu.item('Back to center', recenter); Menu.item('Hide turtle', hideTurtle); Menu.item('Show turtle', showTurtle);