Pull to refresh

Делаем «mindmap» на Javascript с локальным хранением в базе данных браузера

Reading time 25 min
Views 56K

Это небольшой учебный пример редактора карты памяти. За счёт очень подробных комментариев и простого кода, понять его не составит проблем. Статья предназначена для знающих и изучающих Javascript.

Я опишу особенности создания редактора карты памяти, который использует базу данных браузера. Причём, это будет не LocalStorage, который не может превышать 5 мегабайт. Объём данных сможет превысить 100-200 мегабайт, так как используется IndexedDB или webSQL, смотря что доступно в конкретном браузере.

Исходники выложены в открытый доступ на Github.

Мы уложимся в 520 строк кода, при этом в нашей карте можно будет перетаскивать узлы между собой, удалять, переименовывать и создавать новые. А также можно будет назначать одну из 120 иконок через контекстное меню.

Секрет минимализма в том, что мы будем использовать проверенные в бою плагины:
  1. Ydn.db — хранение информации в базе данных браузера с автоматическим выбором лучшего метода и единым API
  2. jQuery context menu — контекстное меню, которое можно наполнять динамически при помощи Javascript
  3. jsPlumb — расширение позволяющее рисовать линии между HTML элементами
  4. jQuery UI — Drag&drop — перетаскивание элементов между собой


PS: Также мы научимся создавать «синглтон», облегчать себе асинхронное программирование при помощи jQuery и встроенного объекта $.Deferred(), а также при помощи плагина LiveReload, сохраним краску на клавише F5 при изменении свойств CSS и кода в HTML и Javascript.


Код программы с комментариями, для тех, кто торопится

Многие могут дальше не читать, а просто ознакомиться с кодом.
Главный Javascript код с очень подробными комментариями
var API_4_MINDMAP = function(){  //singleton - при многократном запуске инициализируется единожды
	 if ( (typeof arguments.callee.instance=='undefined') ) { //если объект ещё не создан
		 arguments.callee.instance = new function() {
		     var this_api = this; //кэшируем самого себя, чтобы использовать внутри функций

		     var my_all_data = {}; //главный массив с данными
		      
		     var my_all_data_template = { //задаём первоначальные данные, если это первый запуск
				 "n1":{ id:1, parent_id:0, title:"Карта памяти<br>своими руками<br>"+
    			 "с хранением данных<br>в браузере.<br>Javascript" },
    			 "n2":{ id:2, parent_id:1, title:"Изучим", icon:"icon-gift" },
    			 "n3":{ id:3, parent_id:2, title:"Рисуем линии между элементами", icon:"icon-flow-line" }, 
    			 "n5":{ id:5, parent_id:3, title:"Используем плагин jsPlumb", icon: "icon-link" }, 
    			 "n4":{ id:4, parent_id:3, title:"Каждая линия - отдельный SVG" }, 
    			 "n7":{ id:7, parent_id:6, title:"Используем плагин jQuery ContextMenu", icon: "icon-link" }, 
    			 "n8":{ id:8, parent_id:1, title:"Объём кода", icon: "icon-lamp" }, 
    			 "n9":{ id:9, parent_id:8, title:"Javascript + jQuery — 520 строк" },
    			 "n10":{ id:10, parent_id:2, title:"Сохраненяем данные в браузере", icon: "icon-floppy-1" },
    			 "n11":{ id:11, parent_id:17, title:"IndexedDB" },
    			 "n12":{ id:12, parent_id:17, title:"webSQL" },
    			 "n13":{ id:13, parent_id:17, title:"LocalStorage" },
    			 "n14":{ id:14, parent_id:10, title:"Используем плагин Ydn.db", icon: "icon-link" },
    			 "n15":{ id:15, parent_id:10, title:"Объём данных не ограничен" },
    			 "n16":{ id:16, parent_id:2, title:"Используем синглтон в Javascript", icon: "icon-cd" },
    			 "n17":{ id:17, parent_id:10, title:"Доступны" },
    			 "n18":{ id:18, parent_id:6, title:"Динамическое добавление пунктов" },
    			 "n20":{ id:20, parent_id:8, title:"CSS — 220 строк" },
    			 "n19":{ id:19, parent_id:8, title:"HTML — 50 строк" },
    			 "n22":{ id:22, parent_id:16, title:"Это позволяет избежать глобальных переменных" },
    			 "n23":{ id:23, parent_id:16, title:"Наводим порядок среди функций" },
    			 "n24":{ id:24, parent_id:2, title:"Используем иконочный шрифт", icon: "icon-emo-wink" },
    			 "n6":{ id:6, parent_id:2, title:"Контекстное меню", icon: "icon-list" }, 
    			 "n25":{ id:25, parent_id:24, title:"Используем набор шрифтов Fontello", icon: "icon-link" },
    			 "n27":{ id:27, parent_id:2, title:"Drag&Drop jQuery UI", icon: "icon-link" },
    			 "n26":{ id:26, parent_id:24, title:"Векторные иконки с идеальным сглаживанием" }
    		   };
		 	
		 	 this.jsSaveAllToDB = function() { //сохраняем весь массив в базу данных
			 	 $.each(my_all_data, function(i, el){
		       		db.put("mindmap_db", el ).done(function(){ 
		       		});
			 	 });
		 	 }

		 	 this.jsLoadAllFromDB = function() { //загружаем весь массив из базы данных браузера или из массива
			 	 var d=new $.Deferred(); //объект позволяющий работать асинхронно

		 	 	 my_all_data = {}; //обнуляем данные
	    		 db.values("mindmap_db",null,99999999).done(function(records) {
	    		 	if(records.length && false) {
	    		 	$.each(records, function(i, el){
		    		 	my_all_data["n"+el.id] = {};
		    		 	my_all_data["n"+el.id] = el;
	    		 	});
	    		 	} else { //если это первый запуск, заполняю данные по шаблону и сохраняю в базе данных
		    		 	my_all_data = my_all_data_template;
		    		 	this_api.jsSaveAllToDB();
	    		 	}
	    		 	d.resolve(); //выполняем обещание, при этом выполнится функция done
	    		 });
	    		 
	    		 return d.promise(); //говорим, что скоро выполним обещание, когда всё загрузится

		 	 }
		 	
		 	 this.jsFind = function(id, changes) { //возвращаем элемент с id или меняем его параметры
		 	 	
		 	 	 //находим элемент в массиве объектов, буква n нужна для отработки отрицательных id
		 	 	 var answer = my_all_data["n"+id]; 
		 	 	 if(!answer) return false; //если элемента в массиве нет
		 	 
		 	 	 if(changes) { //если нужно внести изменения, присваиваем их по очереди
			 	 	 $.each(changes, function(name_field, new_field_value){
				 	 	 answer[name_field] = new_field_value;
			 	 	 });
		       		 
		       		 db.put("mindmap_db", answer ).done(function(){ //асинхронно сохраняем данные в базе браузера
		       		 	console.info("Изменения сохранены в базу данных браузера"); //выводим в консоль браузера
		       		 });
			 	 	 
		 	 	 }
			 	 return answer;
		 	 }
		 	 
		 	 this.jsFindByParent = function(parent_id) { //подбираем всех детей родителя parent_id
		 	 	 var answer = [];
			 	 $.each(my_all_data, function(i,el){ //фильтруем все неудалённые элементы с родителем = parent_id
				 	if((el.parent_id == parent_id) && (!el.del)) answer.push(el);
			 	 });
			 	 return answer;
		 	 }
		 	 
		 	 this.jsAddNew = function(parent_id, title) { //добавляем нового ребёнка родителю parent_id
		 	 	var max_id = 0;
		 	 	$.each(my_all_data, function(i,el){ //находим максимальный id
			 	 	if(el.id>max_id) max_id = el.id;
		 	 	});
		 	 	var new_id = (parseInt(max_id)+1); //новый неиспользованный id
		 	 	my_all_data["n"+new_id] = {}; //создаём новый объект
		 	 	my_all_data["n"+new_id] = {id:new_id, parent_id: parent_id, title: title}; //присваиваем заголовок
		 	 	
		 	 	return new_id;
			 }
			 
		 	 //рекурсивно перебирает ВСЕХ детей, внуков и так далее
		 	 this.jsRecursiveByParent = function(id, recursive_array) {
		 	   if(!recursive_array) recursive_array = [];
		 	   
		 	   var answer = this_api.jsFindByParent(id);
		 	   
		 	   $.each(answer,function(i,el) { //обходим все элементы и вызываем сами себя, пока есть дети
		 	   	   recursive_array.push(el);
		 	       recursive_array = this_api.jsRecursiveByParent(el.id, recursive_array);
		 	   });
		 	 return recursive_array;
		 	 }
		 	 

		 	 this.jsDeleteById = function(id) { //удаляем всех детей и потомков этого родителя
		 	 	 if(confirm("Удалить элемент №"+id+" и его содержимое?")) {
		 	 	 	var childs = this_api.jsRecursiveByParent(id);
		 	 	 	$.each(childs, function(i, el){
		 	 	 		api4mindmap.jsFind(el.id, {del:1}); //"джихад" - сначала удаляем детей и всех потомков
		 	 	 	});
		 	 	 	if(id!=1) api4mindmap.jsFind(id, {del:1}); //потом родителя, если это не №1
		 	 	 }
		 	 }
		 	 
		 	 this.jsRenderAllMap = function(focus_id) { //выводим все элементы карты на экран
		 	 	 if(!focus_id) focus_id = 1;
			 	 var html = "<ul myid='"+focus_id+"'>";
			 	 html = this_api.jsRenderOneParent(focus_id, html); //рекурсивная функция
			 	 html += "</ul>";
			 	 $("#mindmap").html(html);
			 	 jsMakeDroppable(); //делаем новые элементы перетаскиваемыми
		 	 }
		 	 
		 	 this.jsRenderOneParent = function(parent_id, html) { //рисуем элемент и всех потомков
			 	 html += "<li id='node_"+parent_id+"' myid='"+parent_id+"'>";
			 	 html += "<div class='big_n_title'>";
			 	 html += this_api.jsRenderOneElement(parent_id); //рисуем сам элемент
			 	 html += "</div>";
			 	 
			 	 var childs = this_api.jsFindByParent(parent_id); //подбираем всех детей
			 	 if(childs.length) {
				 	 html += "<ul class='childs' myid='"+parent_id+"'>";
			 	 }
			 	 $.each(childs, function(i,el){
				 	html = this_api.jsRenderOneParent(el.id,html); //рекурсивно вызываем сами себя, пока есть дети
			 	 });
			 	 if(childs.length) {
				 	 html += "</ul>";
			 	 }
			 	 
			 	 html += "</li>";
			 	 return html;
		 	 }
		 	 
		 	 
		 	 this.jsRenderOneElement = function(id) { //рисуем один элемент
		 	 	 var element = this_api.jsFind(id); //сам элемент
		 	 	 var childs_count = this_api.jsFindByParent(id).length; //кол-во детей у элемента

		 	 	 var icon_type = '';
		 	 	 if(element.icon) icon_type = element.icon; //если сохранена иконка, используем её
		 	 	 
		 	 	 if(childs_count>0) { //если это папка
		 	 	 	var collapser_html = "<div class='collapse'></div>"; //круглый минус или плюс, для сворачивания
			 	 	var icon = "<div class='type_icon'><i class='icon-folder-1 folder'><div class='count'>"+
			 	 		childs_count+"</div></i><i class='"+icon_type+"'></i>"+"</div>";
		 	 	 } else {
			 	 	var collapser_html = "";
			 	 	var icon = "<div class='type_icon'><i class='"+icon_type+"'></i></div>";
		 	 	 }
		 	 	 
			 	 var answer = icon+"<div class='n_title' contenteditable='true'>"+element.title+
			 	 			       "</div><div class='contextmenu'></div>"+collapser_html;
			 	 return answer; 
		 	 }
		   	 	
			 this.jsDrawMindmap = function(focus_id) { //функция рисует линии между элементами
			 
			    var line_cache = [];
			    
			    $("#mindmap ul:visible").each(function(){ //исключаем свёрнутые списки ul
			    	var ul_id = $(this).attr("myid");
			    	var childs = this_api.jsFindByParent(ul_id);

			     	$.each(childs, function(i,el){ //для наглядности, сначала заполняем массив нужных линий
			    	 	var target = el.id;
			    	 	if(!$("li[myid='"+target+"']"+" .big_n_title:first").hasClass("_jsPlumb_endpoint_anchor_")) {
			    		 	var parent_id = el.parent_id;
			    		 	line_cache.push( {source: parent_id, target: target} );
			    	 	}
			     	});
			    });
			    
			    if(line_cache.length) { //запускаем кеширование отрисовки линий, чтобы всё происходило быстрее
			     	if(!myjsPlumb.isSuspendDrawing()) {
			     		myjsPlumb.setSuspendDrawing(true, true);
			     		console.info("set_suspend");
			     	}
			    }
			    
			    
			    $.each(line_cache, function(i, el){
				      
				      if(el.source == 1) { //у первого элемента линия начинается с половины высоты
				      	anchor1 = [ 1, 0.5, 1, 0, -1, -1 ];
				      } else {
				      	anchor1 = [ 1, 1, 1, 0, -1, -1 ]; //линия идёт с низа
				      }
			    
					  //первая точка для линии:
		    	      var p1 = myjsPlumb.addEndpoint("node_"+el.source+" .big_n_title:first", 
		    	      		                        { anchor: anchor1 });			    	      
		    	      //вторая точка для линии:		                        
		    	      var p2 = myjsPlumb.addEndpoint("node_"+el.target+" .big_n_title:first", 
		    	      								{ anchor: [ 0, 1, -1, 0, 1, -1 ]});
					  //сколько детей у элемента:
					  var count = this_api.jsFindByParent(el.source).length;

					  if(count>10) { //если больше десяти, то линии будут прямыми
			    	      var LineType = "Straight";
					  } else {
			    	      var LineType = "Bezier"; //кривая линия Безье
					  }
			    	  
			    	  //соединяем две точки, которые определили выше  
			  		  myjsPlumb.connect({source: p1, target: p2, scope:"someScope", 
			  		  					deleteEndpointsOnDetach:true, connector:[ LineType, 
			  		  					{ curviness: 30, cornerRadius: 20 } ]});
			   });
			 } //jsDrawMindmap
		   	 	
		   	 this.jsRefreshMindmap = function() { //быстрое обновление всей карты на экране с сохранением состояния
		   	 	 myjsPlumb.reset(); //стираем все линии
		   	 	 var save_scroll_top = $("#mindmap").scrollTop();  //сохраняем позиции скроллинга, чтобы вернуть
		   	 	 var save_scroll_left = $("#mindmap").scrollLeft();//всё как было после перереисовки
		   	 	 
		   	 	 var hidden_elements = []; //массив хранения свёрнутых элементов
		   	 	 
		   	 	 $(".hide").each(function(){
			   	 	hidden_elements.push($(this).attr("myid"));
		   	 	 });
		   	 	 
			   	 api4mindmap.jsRenderAllMap(1); //перерисовываем всю карту заново

		   	 	 $.each(hidden_elements, function(i, el){ //скрываем элементы, которые были скрыты до.
			   	 	$("#node_"+el).addClass("hide");
		   	 	 });
		   	 	 
		   	 	 api4mindmap.jsDrawMindmap(1);  //намечаем линии, взяв видимые узлы с экрана
		   	 	 onResize(); //запускаем отрисовку закешированных линий
		   	 	 
		   	 	 $("#mindmap").scrollTop(save_scroll_top);  //сохраняем позиции скроллинга, чтобы вернуть
		   	 	 $("#mindmap").scrollLeft(save_scroll_left);//всё как было после перереисовки

		   	 }
		   	 	
		 	 this.jsRegAllKeys = function() { //регистрируем клики в элементы

			 	 $("#mindmap").on("keydown", ".n_title", function(e){ //отработка нажатия Enter
					 
			 	 	 if(e.keyCode==13) {
				 	 	e.preventDefault();
			 	 	 	$(this).blur(); //уводим фокус, при этом автоматом сохраняются данные
			 	 	 }
			 	 });

			 	 $("#mindmap").on("keyup", ".n_title", function(e){
					 e.preventDefault();
			 	 	 if(e.keyCode==13) $(this).blur(); 
				 	 onResize(); //перерисовываем линии, так как всё, скорее всего, сдвинулось
			 	 });

			 	 $("#mindmap").on("blur", ".n_title", function(){ //при уводе фокуса, сохраняем заголовок
			 	 	 var n_title_text = $(this).html();
			 	 	 var id = $(this).parents("li:first").attr("myid");
			 	 	 if(n_title_text.length==0) n_title_text = "Новый элемент"; //если всё стёрли, заголовок по умолч.
			 	 	 $(this).html( strip_tags(n_title_text) ); //убираем теги и переносы строк
			 	 	 this_api.jsFind(id, {title:n_title_text}); //сохраняем новый заголовок в массиве и базе данных
				 	 onResize(); //перерисовываем линии
			 	 });

			 	 $("#mindmap").on("click", ".n_title", function(){ //при клике в заголовок, фокусируемся
			 	 	$(this).focus();
			 	 });

			 	 $("#mindmap").on("focus", ".n_title", function(){ //при фокусе, выделяем весь текст
			 	 	var ntitle = $(this);
 	 		 	  	setTimeout(function(){ 
		 	  		if(ntitle.is(":focus")) document.execCommand('selectAll',false,null); 
		 	  		},3); //нужна задержка перед выделением всего текста специально для Firefox

			 	 });
			 	 
			 	 $("#mindmap").on("click", ".collapse", function(){ //при сворачивании и разворачивании узлов
			 	 	$(this).parents("li:first").toggleClass("hide"); //инвертирует класс
			 	 	api4mindmap.jsDrawMindmap(1);  //дорисовываем линии, которых нет
			 	 	onResize();
			 	 	return false;
			 	 });

			 	 var font_size = 14; //шрифт по умолчанию
			 	 $("#zoom_in").on("click", function(){ //кнопка увеличения масштаба
			 	 	font_size += 1;
			 	 	$("#mindmap").css("font-size", font_size+"px");
			 	 	onResize();
			 	 	return false;
			 	 });
			 	 $("#zoom_out").on("click", function(){ //кнопка уменьшения масштаба
			 	 	font_size -= 1;
			 	 	$("#mindmap").css("font-size", font_size+"px");
			 	 	onResize();
			 	 	return false;
			 	 });
			 	 
			 	 $("#collapse_all").on("click", function(){ //кнопка "свернуть все элементы"
			 	 	$("#node_1 ul li").addClass("hide");
			 	 	onResize();
			 	 	return false;
			 	 });

			 	 $("#expand_all").on("click", function(){ //кнопка "развернуть все элементы"
			 	 	$("#node_1 ul li").removeClass("hide"); 
			 	 	onResize();
			 	 	return false;
			 	 });
			 	 

		 	 } //jsRegAllKeys
		   	 	
		   	 	
		 }
     }
     return arguments.callee.instance; //возвращаем все функции
}

function onResize() {
	myjsPlumb.setSuspendDrawing(false, true); //перерисовывает закешированные линии
}

function jsGetIcons(n) { //формируем многоуровневое меню иконок
	var icons = {};
	
    icons[0] = ["progress-0","progress-1","progress-2","progress-3","dot","dot-2","dot-3","star-empty","star","record"];
    icons[1] = ["check","heart-empty","heart","bookmark-empty","bookmark","ok-2","help","wallet","mail-2","cloud"];
    icons[2] = ["tree","chat-2","article-alt","volume","flash","aperture-alt","layers","steering-wheel","skiing","flight"];
    icons[3] = ["lock-open","lock","umbrella","camera","book-open","clock-1","plus","minus","trash","music"];
    icons[4] = ["calculator","address","pin","vcard","basket-1","swimming","youtube","leaf","mic","target"];
    icons[5] = ["monitor","phone","download","bell","at","pause","play","stop-1","flag","key"];
    icons[6] = ["users-1","eye","inbox","brush","moon","college","fast-food","coffee","top-list","bag"];
    icons[7] = ["chart-area","info","home-1","hourglass","attention","scissors","tint","guidedog","archive","flow-line"];
    icons[8] = ["emo-grin","emo-happy","emo-wink","emo-sunglasses","emo-thumbsup","emo-sleep","emo-unhappy","emo-devil","emo-surprised","emo-tongue"];
    icons[9] = ["plus","minus","keyboard","fast-fw","to-end","to-start","cancel-circle","check","flash","feather"];
    icons[10] = ["plus-circle","pencil-alt","target-1","chart-pie","adjust","user-add","volume","install","flow-cascade","sitemap"];
    icons[11] = ["minus-circle","clock-1","light-down","light-up","lamp","upload","picture-2","dollar","gift","link-1"];
			
	answer = {};	

	$.each(icons, function(j, icon_group){
		sub_icons = {};
		$.each(icons[j], function(i, icon){
			sub_icons["icon-"+icon] = {};
			sub_icons["icon-"+icon] = {name:icon, icon: "icon-"+icon};
		});

		answer["icon-group"+icon_group]	= {};
		answer["icon-group"+icon_group]	= {name:"Набор №"+(parseInt(j)+1), icon: "icon-"+icons[j][0], items: sub_icons};
		
	});	
			
	return answer; //создали элемент для контекстного меню
}

function jsMakeDroppable() { //делаем все элементы перетаскиваемыми

		$(".n_title").not("ui-draggable").draggable({
				zIndex: 999,
				delay:50,
				revert: false,      // will cause the event to go back to its
				helper:"clone",
				appendTo: "body",
				refreshPositions:true
			});

		$( ".n_title" ).not("ui-droppable").droppable({
			accept: ".n_title",
			activeClass: "ui-can-recieve",
			tolerance: "pointer",
			hoverClass: "ui-can-hover",
			over: function (event, ui) {
				//$(this).click();
				},
            drop: function( event, ui ) {
            	//console.info("drop-all",usedOverlays,ui,ui.draggable[0] );
            	
            	var my_draggable = $(ui.draggable[0]);
            	var my_droppable = $(event.target);
            	
            	my_draggable_id = my_draggable.parents("li:first").attr("myid");
            	my_droppable_id = my_droppable.parents("li:first").attr("myid");
            	
            	if( jsCanDrop(my_draggable_id, my_droppable_id) ) { //проверяем, чтобы небыло зацикливаний
					api4mindmap.jsFind(my_draggable_id, {parent_id:my_droppable_id});
					api4mindmap.jsRefreshMindmap();
	   				$(".ui-draggable-dragging").remove(); //удаляем клон объекта, который перетаскивали

            	} else {
					alert("Не могу перенести элемент внутрь самого себя");
            	}
            	
				}
			});

}

function jsCanDrop(draggable_id, droppable_id) { //предотвращаем зацикливание, чтобы родитель не был внуком своих детей
	var can_drop = true;
	var all_childs = api4mindmap.jsRecursiveByParent(my_draggable_id);
	$.each(all_childs, function(i,el){
		console.info(el.id, droppable_id);
		if(el.id == droppable_id) 
			can_drop = false;
	});
	
	if(draggable_id == droppable_id) var can_drop = false;
	
	return can_drop;
}

// чистим ввод текста от тегов
function strip_tags( str ){	
	if(!str) return "";
	answer = str.replace(/<\/?[^>]+>/gi, '');
	answer = answer.replace(/\n/gi, '');
	return answer;
}

var myjsPlumb; //глобальный объект для рисования
///////////////////////запускается после загрузки html страницы////////////////////////
function jsDoFirst() { 
	api4mindmap = new API_4_MINDMAP(); //регистрируем собственное api из "синглтона"

	jsPlumb.Defaults.Container = $("#mindmap"); //параметры "рисовальщика" линий
    myjsPlumb = jsPlumb.getInstance({
    	DragOptions: { cursor: 'pointer', zIndex: 2000 },
    	PaintStyle:{ 
    	  lineWidth:1, 
    	  strokeStyle:"#888"
    	},
    	Connector:[ "Bezier", { curviness: 30 } ],
    	Endpoint:[ "Blank", { radius:5 } ],
    	EndpointStyle : { fillStyle: "#567567"  },
    	Anchors : [[ 1, 1, 1, 0, -1, -1 ],[ 0, 1, -1, 0, 1, -1 ]]
    });
    
    var icons_html = jsGetIcons(0); //берём все иконки и их группы для контекстного меню

	$.contextMenu({ //генерируем контекстное меню заранее и назначаем на левый клик в .contextmenu
        selector: '.contextmenu', 
        trigger: 'left',
        callback: function(key, options) {
        	var id = $(this).parents("li:first").attr("myid");
            if( /icon-/ig.test(key) ) { //назначаем иконку
            	api4mindmap.jsFind(id, {icon:key});
	            api4mindmap.jsRefreshMindmap();
            } else if(key == "delete") { //удаляем элемент и потомков
	           api4mindmap.jsDeleteById(id);
	           api4mindmap.jsRefreshMindmap(id);
            } else if(key == "add_down") { //добавляем вниз
            	var parent_id = api4mindmap.jsFind(id).parent_id;
	            var new_id = api4mindmap.jsAddNew(parent_id, "Новый элемент");
	            api4mindmap.jsRefreshMindmap();
	            $("#node_"+new_id+" .n_title").focus();
            } else if(key == "add_right") { //добавляем внутрь
	            var new_id = api4mindmap.jsAddNew(id, "Новый элемент");
	            $(this).parents("li").removeClass("hide");
	            api4mindmap.jsRefreshMindmap();
	            $("#node_"+new_id+" .n_title").focus();
            }
        },
        delay:0,
        items: {
        	"add_down": {"name":"Добавить вниз", "icon": "icon-down-1"},
        	"add_right": {"name":"Добавить вправо", "icon": "icon-right-1"},
        	"sep1": "--------",
        	"delete": {"name":"Удалить", "icon": "icon-trash"},
            "context_make_did1011": {"name": "Иконка", "icon": "icon-emo-wink", 
	            "items": icons_html //сгенерированные пункты меню с иконками
            }
		}
		});	
	
  	//схема структуры базы данных
  	var mindmap_store_schema = { //схема базы данных
  	  name: "mindmap_db",  //название таблицы
  	  keyPath: 'id', // ключ по которому мы будем искать данные, 
  	  autoIncrement: false 
  	};
  	
  	var schema = { //все схемы таблиц
  	  stores: [mindmap_store_schema]
  	}; 
  		  	
  	if( navigator.userAgent.toLowerCase().indexOf("android") !=-1 ) {	  	
  		var options = {mechanisms: ['websql', 'indexeddb']};  //предпочитать websql в андроид
    } else {
    	var options = {}; //предпочитать indexeddb он быстрый и асинхронный), а потом уже все остальные
    }
	
	db = new ydn.db.Storage('_all_mindmap', schema, options); //инициализируем базу данных браузера

	api4mindmap.jsLoadAllFromDB().done(function(){ //асинхронный вызов загрузки данных из базы данных браузера
		api4mindmap.jsRegAllKeys();    //регистрируем клавиши
		api4mindmap.jsRenderAllMap(1); //рисуем карту для узла №1
		api4mindmap.jsDrawMindmap(1);  //рисуем линии, беря видимые узлы с экрана
	 	onResize(); //перерисовываем линии
	}); //загружаем весь массив из базы данных браузера
	
}




Инструменты которыми я пользуюсь при программировании

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

Программирую я в Coda, а иногда в Sublime Text. Coda привычнее, но чуть подтормаживает раскрашивая код (она только под Мак), а Sublime Text — очень быстрый и работает на любой платформе, но, во первых, я ещё к нему не привык, а во вторых, люблю заходить прямо в Coda на сервер, чтобы быстро исправить несколько файлов. И терминалом для связи с Debian пользуюсь через Coda.

Настоящим прорывом в скорости работы для меня стало открытие GIT. Это система контроля версий. Использую её через официальную программу GitHub:


Github использую как облачное хранение своего кода, а также публикую релизы с его помощью. Чтобы «зарелизить» версию своего сайта, я делаю так:
  1. Проверяю работу сайта на локальной машине и делаю «Коммит», т.е. подтверждаю изменения (см.картинку выше, кнопка Commit)
  2. «Пушу» изменения на сервер одной кнопкой (в том же окне внизу есть кнопка Sync)
  3. Захожу в терминал на свой сервер в Германии по SSH и запускаю комманду:
    git pull httрs://myuser_name:mysslpassword@github.com/Imater/tree.git master
  4. Запускаю скрипт, который сжимает все JS и CSS файлы, выкидывая из них комментарии и ZIP-уя их в один файл js и один файл css. Чем меньше файлов вы вставляете в свою HTML страничку, тем быстрее загружается сайт


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


Научитесь работать с консолью и Developer Tools и будете экономить много времени.

Теперь поговорим о краске на клавише F5 (в случае MacOS — cmd+r). Совсем недавно открыл для себя LiveReload. Есть версия и под Win и под Mac.

Устанавливаете программу, потом ставите плагин под Хром или другой браузер или вставляете код сразу за тегом body в основном HTML файле:
<script>document.write('<script src=\"http://' + (location.host || 'localhost').split(':')[0] + ':35729/livereload.js?snipver=1\"></' + 'script>')</script>


И после этого получаете массу удовольствия. Сценарий такой: открываете свой сайт, который программируете, потом пишете код в CSS или Javascript или заменяете картинки в папке, которую «мониторит» livereload, и как только вы сохранили, сайт сразу обновляется. Если вы заменили CSS, то изменения применяются мгновенно без перезагрузки страницы.

Ну и в конце, описания инструментов, хочу рекомендовать уменьшитель картинок PNG, который экономит до 70% объёма и, тем самым, ускоряет загрузку сайта.

Описание кода программы для создания карты памяти

Весь код выложен на Github. Вы можете скачать его и развернуть в любую папку. Всё что нужно, уже в репозитории. Все плагины в папке. Комментарии в коде очень подробные.

Начнём с основ. Я люблю оборачивать всё множество функций в "синглтон". Делаю это следующим образом:
var API_4_MINDMAP = function(){  //singleton - при многократном запуске инициализируется единожды
	 if ( (typeof arguments.callee.instance=='undefined') ) { //если объект ещё не создан
		 arguments.callee.instance = new function() {
		     var my_all_data = {};
		     var this_api = this; //кэшируем самого себя, чтобы использовать внутри асинхронных функций
		     this.jsAlert = function( name ) {
		        if( prompt("Hello "+name+", are you ok?") ) {
		           var is_ok = true;
		        } else {
		           var is_ok = false;
		        }
		        my_all_data[name] = {};
		        my_all_data[name] = {name: name, hi_is_ok: is_ok};
		        return my_all_data;
		     }                     
     }
     return arguments.callee.instance; //возвращаем все функции
}

api4mindmap = new API_4_MINDMAP(); //регистрируем собственное api из "синглтона"
console.info( api4mindmap.jsAlert("Habrahabr") );


Преимущество такого подхода в возможности сохранять большие массивы данных внутри такого «синглтона» и не бояться, что пользователь или какой либо плагин изменит данные. Мы изолируем функции и переменные. А из практики, мне нравится набирать в консоли: «api4mindmap.js...» и ждать всплывающий список всех функций, которые я создал. Быстро и удобно.

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

Теперь поговорим о плагинах, которые мы используем:

Ydn.db — база данных браузера


Это плагин для jQuery, который позволяет при помощи единого api сохранять и считывать данные из локальных баз данных браузеров. Работает и на любых платформах, в том числе на мобильных. Даже в Phonegap.

Я не пользуюсь большинством его функций, а использую, в основном в следующих случаях:
 this.jsLoadAllFromDB = function() { //загружаем весь массив из базы данных браузера или из массива
	 var d=new $.Deferred(); //объект позволяющий работать асинхронно

 	 my_all_data = {}; //обнуляем данные
    db.values("mindmap_db",null,99999999).done(function(records) {
	 	if(records.length) {
	 	$.each(records, function(i, el){
		 	my_all_data["n"+el.id] = {};
		 	my_all_data["n"+el.id] = el;
	 	});
	 	} else { //если это первый запуск, заполняю данные по шаблону и сохраняю в базе данных
		 	my_all_data = my_all_data_template;
		 	this_api.jsSaveAllToDB();
 	}
 	d.resolve(); //выполняем обещание, при этом выполнится функция done
 });
 
 return d.promise(); //говорим, что скоро выполним обещание, когда всё загрузится

 }

 this.jsFind = function(id, changes) { //возвращаем элемент с id или меняем его параметры
 	
 	 //находим элемент в массиве объектов, буква n нужна для отработки отрицательных id
 	 var answer = my_all_data["n"+id]; 
 	 if(!answer) return false; //если элемента в массиве нет
 
 	 if(changes) { //если нужно внести изменения, присваиваем их по очереди
	 	 $.each(changes, function(name_field, new_field_value){
 	 	 answer[name_field] = new_field_value;
	 	 });
  		 
  		 db.put("mindmap_db", answer ).done(function(){ //асинхронно сохраняем данные в базе браузера
  		 	console.info("Изменения сохранены в базу данных браузера"); //выводим в консоль браузера
  		 });
	 	 
 	 }
	 return answer;
 }

Это пример из кода для редактора карт памяти. Тут очень простые команды: db.values(«mindmap_db»,null,99999999) — считывает все элементы из таблицы IndexedDb (если в Хроме) и возвращает через некоторое время в функцию которая в параметрах .done(). Так вы можете считывать элементы из базы данных. Можно считать один элемент при помощи команды: db.get(«mindmap_db»,5) — так вы считаете элемент с id = 5.

Для записи в базы данных используется команда: db.put(«mindmap_db», answer ). Так как эта команда асинхронна и браузер не ждёт её выполнения, она не замедляет работу. Именно поэтому эта программа будет работать в Chrome быстрее.

Для работы с асинхронными командами, применяю встроенный в jQuery объект $.Deferred(). Пример приведён выше. Вы просто обещаете в конце функции, что вернёте результат как только, так сразу, при помощи команды: return d.promise(x); А потом когда у вас всё в функции выполнилось, например данные отправлены на сервер, вы выполняете d.resolve(200); Тогда выполняется .done() функция и ей передаётся параметр x.

Это очень удобно, так как позволяет не писать функции внутри друг друга. А ещё, рекомендую изучить команду $.when. Я её использую тогда, когда у нас есть много асинхронных функций, которые мы запустили одновременно и хотим выполнить кое-что сразу после завершения всех асинхронных. Вот пример:
function jsDo() {
  var dfdArray = [];
  for(var i=0; i<1000; i++) {
    dfdArray.push( jsAsync() );
  }
  $.when.apply(null, dfdArray).then( function(){ alert("Все функции выполнены") } );
}

function jsAcync() {
  var d = new $.Deferred();
  setTimeout(function(){
     d.resolve();
  }, Math.random()*5000 );
 return d.promise();
}

По сути, тут все «обещания» набираются в массив и передаются команде $.when, которая выполняет функцию ".done" ровно в тот момент, когда последнее обещание выполнено.

Ydn.db позволяет работать с данными, не задумываясь о методах их хранения в браузере, так как оборачивает их многообразие в единый api. Он, в том числе, позволяет работать с индексами, делать отборы, накладывать фильтры, вычислять суммы в таблицах и так далее. Но в данном примере, мы используем его только для хранения и считывания данных, а роль индекса у нас выполняет массив my_all_data, это обеспечивает очень большую скорость. При этом сохранность данных обеспечивается тем, что функция api4mindmap.jsFind(id, {title: «new_title»}), обновляет этот массив сразу, а в базу отправляет данные асинхронно. Но, тем не менее, вы можете изменить заголовок узла в карте памяти и тут же обновить браузер, а все данные при этом сохранятся. База данных в браузере работает быстро, надёжно и имеет возможность хранить больше 100 мегабайт информации.

Изучив Ydn.db, можно забыть про 5 мегабайт ограничений LocalStorage. Единственное, что пользователя будут спрашивать разрешение на хранение данных каждые 5 мегабайт (Chrome не спрашивает).

Этот плагин отлично работает на Android и в iOS. Но помните, чтобы избежать ошибок, возможно вам придётся отключить выбор IndexedDB в Android. Что примечательно, в последних версиях Internet Explorer используется IndexedDB, что ускоряет работу подобных приложений за счёт асинхронности. Если вы разом сохраняете 1000 элементов, они будут делать это параллельно, но с разной скоростью, что быстрее, чем последовательная запись.

jsPlumb — рисуем линии SVG в любом браузере


Для рисования линий между разными элементами html страницы можно было бы использовать Canvas, но он имеет множество недостатков. Вам пришлось бы создать Canvas значительного размера, например 4000 x 4000 px, что привело бы к нестабильной работе браузера. Линии были бы растровыми и на современных экранах Retina, смотрелись бы хуже, чем векторные. А самое страшное, что все эти 16 миллионов пикселей, требовали бы перерисовки при каждом вводе буквы в карту памяти.

jsPlumb рисует каждую линию в SVG и размещает его в нужное место, ставя ему CSS свойство absolute и рассчитанные координаты. Есть возможность мгновенно перерисовать все линии одной командой. По сути, вам достаточно один раз указать начальную и конечную точку, затем соединить их линией и вы можете забыть про плагин. Практически 3 команды и вы умеете обращаться с линиями.

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


Изучите, пригодится для рисования: иерархических структур, простых графиков, обучалок (которые показывают линией со стрелкой на элемент про который говорят), организационных структур предприятий, диаграмм и так далее. Работает практически во всех браузерах, в том числе на iOS и Android.

jQuery context menu — контекстное меню

Все плагины меню которые я видел, используют для создания своей иерархии html. А в этом плагине можно создавать структуру в простом, практически JSON формате, при помощи массива объектов.


Задать набор команд в меню можно при помощи вот такого кода:
	$.contextMenu({ //назначаем на левый клик в .contextmenu
        selector: '.contextmenu', 
        trigger: 'left',
        callback: function(key, options) {
        	var id = $(this).parents("li:first").attr("myid");
            if( /icon-/ig.test(key) ) { //назначаем иконку
            	api4mindmap.jsFind(id, {icon:key});
	            api4mindmap.jsRefreshMindmap();
            } else if(key == "delete") { //удаляем элемент и потомков
	           api4mindmap.jsDeleteById(id);
	           api4mindmap.jsRefreshMindmap(id);
            } else if(key == "add_down") { //добавляем вниз
            	var parent_id = api4mindmap.jsFind(id).parent_id;
	            var new_id = api4mindmap.jsAddNew(parent_id, "Новый элемент");
	            api4mindmap.jsRefreshMindmap();
	            $("#node_"+new_id+" .n_title").focus();
            } else if(key == "add_right") { //добавляем внутрь
	            var new_id = api4mindmap.jsAddNew(id, "Новый элемент");
	            $(this).parents("li").removeClass("hide");
	            api4mindmap.jsRefreshMindmap();
	            $("#node_"+new_id+" .n_title").focus();
            }
        },
        delay:0,
        items: {
        	"add_down": {"name":"Добавить вниз", "icon": "icon-down-1"},
        	"add_right": {"name":"Добавить вправо", "icon": "icon-right-1"},
        	"sep1": "--------",
        	"delete": {"name":"Удалить", "icon": "icon-trash"},
            "context_make_did1011": {"name": "Иконка", "icon": "icon-emo-wink", 
	            "items": icons_html //сгенерированные пункты меню с иконками
            }
		}
		});	



Обратите внимание на пункт items, всё очень понятно и красиво. Тем более, что хранить все функции вызываемые контекстным меню вместе в функции callback, позволяет меньше путаться.

Единственное, что мне пришлось сделать, это подправить скрипты плагина так, чтобы он рисовал не растровые иконки, а иконки из шрифта Fontello.

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

Fontello — шрифт с векторными иконками


Тут как в супермаркете, заходите на сайт и набираете те иконки, которые вам смогут пригодиться:


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

Сами иконки можете оценить в демонстрации карты памяти, зайдя в контекстное меню. Там 120 иконок в естественной среде обитания.

jQuery UI — библиотека плагинов для взаимодействия с пользователем


Это очень знаменитая библиотека в которой есть такие элементы как: выбор даты из календарика, изменение размеров любых элементов, перетаскивание, верхние закладки, сортировка элементов в списке мышкой и так далее. Изучите, не пожалеете.


В данном примере карты памяти, мы используем Drag&drop для перетаскивания узлов карты между другими узлами. Тут всё просто, единственное что нам пришлось «замудрить», это проверку того, что мы не перетаскиваем родителя к своим потомкам, так как это привело бы к зацикливанию дерева.

CSS — рисование карты памяти

Каждый узел у нас выглядит вот так:
<div id="mindmap">
 <ul class='childs'>
  <li>
   <div class='big_n_title'><div class='n_title'></div></div>
   <ul class='childs'>
      ......
   </ul>
  </li>
 </ul>
</div>


Если правильно назначить CSS свойства элементам ul, li, .big_n_title и .n_title, то получается именно такая карта памяти, которую вы видите. Все CSS свойства вы можете посмотреть в исходниках.

По сути, весь секрет в том, что мы делаем так:
#mindmap {
	background-image: url(cross.png);
	background-attachment: scroll;
	white-space: nowrap;
}

#mindmap ul {
	display: inline-block;
	white-space: nowrap;
	vertical-align: middle;
	list-style: none;
}

#mindmap .big_n_title {
	display: inline-block;
	vertical-align: baseline;
	margin-right: 40px;
	position: relative;
}

#mindmap .n_title {
	display: inline-block;
	white-space: normal;
}


Т.е. запрещаем спискам переносить элементы на новую строку, а элементы списков делаем display:inline-block, так что они становятся подобием символов в строке текста. Также мы добавляем центрирование по вертикали, а чтобы оно работало для каждого узла в отдельности, мы оборачиваем узел в div.big_n_title.

Ничего революционного и очень сложного. И всё работает.

Проверено, что если комбинировать этот приём с direction: rtl, то можно рисовать карту и в другую сторону — справа налево. Можно сделать так, чтобы левая часть карты уходила в одну сторону, а правая в другую. Но я больше люблю односторонние карты — их проще читать.

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

Изучайте готовый пример, все исходники открыты.

PS: Если вы «чуть» доработаете пример, вы получите mindmaster и сможете брать с пользователей от 5 до 15 долларов в месяц (шутка).

Спасибо вам за внимание, а jQuery и создателям плагинов за экономию времени.
Tags:
Hubs:
+100
Comments 45
Comments Comments 45

Articles