diff options
32 files changed, 5360 insertions, 130 deletions
diff --git a/build.gradle b/build.gradle index 7a795da3..10dcdd82 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,3 @@ -subprojects { - apply plugin: 'java' - repositories { - mavenCentral() - } -} - buildscript { repositories { mavenCentral() @@ -18,12 +11,34 @@ buildscript { } } +plugins { + id "com.eriwen.gradle.js" version "1.12.1" +} + +subprojects { + apply plugin: 'java' + repositories { + mavenCentral() + } +} + apply plugin: 'war' apply plugin: 'css' apply plugin: 'com.bmuschko.tomcat' apply plugin: 'com.eriwen.gradle.js' +apply plugin: 'com.eriwen.gradle.css' apply plugin: 'com.github.tkruse.groovysh' +////ext.environment = hasProperty('env') ? env : 'dev' +//environments { +// dev { +// +// } +// prod { +// +// } +//} + repositories { mavenCentral() } @@ -47,7 +62,7 @@ project(':deps:com.juick.xmpp') { project(':deps:com.juick.json') { dependencies { - compile core + compile core } } @@ -63,6 +78,7 @@ dependencies { compile 'org.apache.httpcomponents:httpclient:4.5.1' compile 'org.json:json:20151123' compile "org.springframework:spring-jdbc:4.2.4.RELEASE" + compile 'org.rythmengine:rythm-engine:1.0.1' providedCompile 'javax.servlet:javax.servlet-api:3.1.0' runtime 'mysql:mysql-connector-java:5.1.37' def tomcatVersion = '7.0.+' @@ -72,16 +88,22 @@ dependencies { } javascript.source { - dev { + juick { js { srcDir "src/main/webapp" include "*.js" } } + textext { + js { + srcDir "src/main/webapp/textext" + include "*.js" + } + } } combineJs { - source = javascript.source.dev.js.files + source = javascript.source.juick.js.files + javascript.source.textext.js.files dest = file("${buildDir}/scripts.all.js") } @@ -89,19 +111,28 @@ minifyJs { source = combineJs dest = file("${buildDir}/scripts.min.js") sourceMap = file("${buildDir}/scripts.sourcemap.json") + println("webAppDirName=${webAppDir} - ${webAppDirName}") + } + css.source { - dev { + juick { css { srcDir "src/main/webapp" include "*.css" } } + textext { + css { + srcDir "src/main/webapp/textext" + include "*.css" + } + } } combineCss { - source = css.source.dev.css.files + source = css.source.juick.css.files + css.source.textext.css.files dest = "${buildDir}/style.all.css" } @@ -110,6 +141,15 @@ minifyCss { dest = "${buildDir}/style.min.css" } +war { + it.dependsOn minifyCss + it.dependsOn minifyJs + from "${buildDir}/scripts.min.js" + from "${buildDir}/style.min.css" + from "${buildDir}/scripts.all.js" + from "${buildDir}/style.all.css" +} + assemble.dependsOn 'minifyCss' assemble.dependsOn 'minifyJs' diff --git a/src/main/java/com/juick/JuickApplication.java b/src/main/java/com/juick/JuickApplication.java index ce242222..e546f5a8 100644 --- a/src/main/java/com/juick/JuickApplication.java +++ b/src/main/java/com/juick/JuickApplication.java @@ -4,6 +4,7 @@ import com.juick.xmpp.JID; import com.juick.xmpp.Stream; import com.juick.xmpp.StreamComponent; import com.juick.xmpp.s2s.S2SComponent; +import org.rythmengine.Rythm; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; @@ -12,9 +13,7 @@ import java.net.InetSocketAddress; import java.nio.channels.AsynchronousSocketChannel; import java.nio.channels.Channels; import java.nio.channels.CompletionHandler; -import java.util.ArrayList; -import java.util.List; -import java.util.Properties; +import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.logging.Level; @@ -32,17 +31,34 @@ public class JuickApplication { private JdbcTemplate sql; private JdbcTemplate sqlSearch; private List<JuickComponent> components = new ArrayList<>(); + private String applicationPath; - public JuickApplication(Properties conf) throws IOException { + public JuickApplication(Properties conf, String applicationPath) throws IOException { + this.applicationPath = applicationPath; executorService = Executors.newWorkStealingPool(); - DriverManagerDataSource dataSource = new DriverManagerDataSource(); - dataSource.setDriverClassName(conf.getProperty("datasource_driver", "com.mysql.jdbc.Driver")); - dataSource.setUrl(conf.getProperty("datasource_url")); - sql = new JdbcTemplate(dataSource); - DriverManagerDataSource searchDatasource = new DriverManagerDataSource(); - searchDatasource.setDriverClassName("com.mysql.jdbc.Driver"); - searchDatasource.setUrl("jdbc:mysql://127.0.0.1:9306?autoReconnect=true&useUnicode=yes&characterEncoding=utf8&maxAllowedPacket=512000"); - sqlSearch = new JdbcTemplate(searchDatasource); + initDataSources(conf); + initSockets(conf); + initComponents(conf); + initRythm(conf); + } + + private void initRythm(Properties conf) { + String rythmMode = conf.getProperty("rythm_mode", "prod"); + Map<String, Object> map = new HashMap<>(); + map.put("home.template", applicationPath+"/WEB-INF/classes/templates"); + map.put("rythm.engine.mode", rythmMode); + Rythm.init(map); + } + + private void initComponents(Properties conf) { + if (!isHttpDevMode()) { + addComponent(new S2SComponent(this, conf)); + addComponent(new CrosspostComponent(sql, conf)); + addComponent(new PushComponent(sql, conf)); + } + } + + private void initSockets(final Properties conf) throws IOException { AsynchronousSocketChannel socket = AsynchronousSocketChannel.open(); socket.connect(new InetSocketAddress("localhost", 5347), socket, new CompletionHandler<Void, AsynchronousSocketChannel>() { @@ -60,11 +76,17 @@ public class JuickApplication { } } }); - if (!isHttpDevMode()) { - addComponent(new S2SComponent(this, conf)); - addComponent(new CrosspostComponent(sql, conf)); - addComponent(new PushComponent(sql, conf)); - } + } + + private void initDataSources(Properties conf) { + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName(conf.getProperty("datasource_driver", "com.mysql.jdbc.Driver")); + dataSource.setUrl(conf.getProperty("datasource_url")); + sql = new JdbcTemplate(dataSource); + DriverManagerDataSource searchDatasource = new DriverManagerDataSource(); + searchDatasource.setDriverClassName("com.mysql.jdbc.Driver"); + searchDatasource.setUrl("jdbc:mysql://127.0.0.1:9306?autoReconnect=true&useUnicode=yes&characterEncoding=utf8&maxAllowedPacket=512000"); + sqlSearch = new JdbcTemplate(searchDatasource); } public static boolean isHttpDevMode() { diff --git a/src/main/java/com/juick/http/www/Home.java b/src/main/java/com/juick/http/www/Home.java index 3688b58e..b8047190 100644 --- a/src/main/java/com/juick/http/www/Home.java +++ b/src/main/java/com/juick/http/www/Home.java @@ -19,6 +19,7 @@ package com.juick.http.www; import com.juick.server.AdsQueries; import com.juick.server.MessagesQueries; +import org.rythmengine.Rythm; import org.springframework.jdbc.core.JdbcTemplate; import java.io.IOException; @@ -119,17 +120,9 @@ public class Home { out.println("<!--noindex-->"); } + if (visitor != null) { - out.println("<form action=\"/post\" method=\"post\" enctype=\"multipart/form-data\" onsubmit=\"return onsubmitNewMessage()\">"); - out.println("<section id=\"newmessage\">"); - out.println(" <textarea name=\"body\" placeholder=\"Новое сообщение...\" onclick=\"$('#newmessage>div').css('display','block');$('#newmessage textarea').css('min-height','70px');\" onkeypress=\"postformListener(this.form,event)\"></textarea>"); - out.println(" <div>"); - out.println(" <input type=\"text\" class=\"img\" name=\"img\" placeholder=\"Ссылка на изображение (JPG/PNG, до 10Мб)\"/> или <a href=\"#\" onclick=\"return attachMessagePhoto(this)\">загрузить</a><br/>"); - out.println(" <input type=\"text\" class=\"tags\" name=\"tags\" placeholder=\"Теги (через пробел)\"/><br/>"); - out.println(" <input type=\"submit\" class=\"subm\" value=\"Отправить\"/>"); - out.println(" </div>"); - out.println("</section>"); - out.println("</form>"); + out.println(Rythm.render("parts/post_form.html")); } if (mids.size() > 0) { diff --git a/src/main/java/com/juick/http/www/Main.java b/src/main/java/com/juick/http/www/Main.java index f37ae030..babeee1f 100644 --- a/src/main/java/com/juick/http/www/Main.java +++ b/src/main/java/com/juick/http/www/Main.java @@ -77,7 +77,7 @@ public class Main extends HttpServlet implements Stream.StreamListener { conf.getProperty("twitter_consumer_secret")); PageTemplates.sape = new Sape(conf.getProperty("sape_user"), "juick.com", 2000, 3600); Endpoints.wwwJuickCom = conf.getProperty("www_juick_com", "juick.com"); - app = new JuickApplication(conf); + app = new JuickApplication(conf, getServletContext().getRealPath("/")); sql = app.getSql(); sqlSearch = app.getSqlSearch(); pagesNewMessage = new NewMessage(app); @@ -171,6 +171,8 @@ public class Main extends HttpServlet implements Stream.StreamListener { help.doRedirectToHelpIndex(sql, request, response); } else if (uri.startsWith("/help/")) { help.doGetHelp(sql, request, response); + } else if (uri.startsWith("/my_tags.json")) { + pagesUser.doMyTagsJson(sql, request, response); } else if (uri.startsWith("/tag/")) { discover.doGet(sql, sqlSearch, request, response); } else if (uri.matches("^/\\d+$")) { diff --git a/src/main/java/com/juick/http/www/NewMessage.java b/src/main/java/com/juick/http/www/NewMessage.java index d3178c59..c8eb6ede 100644 --- a/src/main/java/com/juick/http/www/NewMessage.java +++ b/src/main/java/com/juick/http/www/NewMessage.java @@ -17,13 +17,10 @@ */ package com.juick.http.www; +import com.alibaba.fastjson.JSONArray; import com.juick.JuickApplication; import com.juick.Tag; -import com.juick.server.CrosspostQueries; -import com.juick.server.MessagesQueries; -import com.juick.server.SubscriptionsQueries; -import com.juick.server.TagQueries; -import com.juick.server.UserQueries; +import com.juick.server.*; import com.juick.xmpp.JID; import com.juick.xmpp.Message; import com.juick.xmpp.Stream; @@ -161,13 +158,25 @@ public class NewMessage { List<com.juick.Tag> tags = new ArrayList<>(); String tagsArr[] = new String[1]; if (tagsStr != null && !tagsStr.isEmpty()) { - tagsArr = tagsStr.split("[ \\,]"); - for (int i = 0; i < tagsArr.length; i++) { - if (tagsArr[i].startsWith("*")) { - tagsArr[i] = tagsArr[i].substring(1); + if (tagsStr.startsWith("[")) { + // new json tags format with auto-completion + JSONArray parse = (JSONArray)JSONArray.parse(tagsStr); + ArrayList<String> tagsList = new ArrayList<>(); + for(int i=0; i<parse.size(); i++) { + if (parse.get(i) instanceof String) { + tagsList.add(parse.getString(i)); + } } - if (tagsArr[i].length() > 64) { - tagsArr[i] = tagsArr[i].substring(0, 64); + tagsArr = tagsList.toArray(new String[tagsList.size()]); + } else { + tagsArr = tagsStr.split("[ \\,]"); + for (int i = 0; i < tagsArr.length; i++) { + if (tagsArr[i].startsWith("*")) { + tagsArr[i] = tagsArr[i].substring(1); + } + if (tagsArr[i].length() > 64) { + tagsArr[i] = tagsArr[i].substring(0, 64); + } } } tags = TagQueries.getTags(sql, tagsArr, true); @@ -289,7 +298,7 @@ public class NewMessage { out.println("<p><a href=\"https://www.facebook.com/sharer/sharer.php?u=" + url + "\" onclick=\"return openSocialWindow(this)\" class=\"ico32-fb sharenew\">Отправить в Facebook</a></p>"); } out.println("<p><a href=\"https://plus.google.com/share?url=" + url + "\" onclick=\"return openSocialWindow(this)\" class=\"ico32-gp sharenew\">Отправить в Google+</a></p>"); - out.println("<p>Ссылка на сообщение: <a href=\"http://juick.com/" + mid + "\">http://juick.com/" + mid + "</a></p>"); + out.println("<p>Ссылка на сообщение: <a href=\"http://"+ Endpoints.wwwJuickCom+"/" + mid + "\">http://"+Endpoints.wwwJuickCom+"/" + mid + "</a></p>"); out.println("</section>"); PageTemplates.pageFooter(request, out, visitor, false); diff --git a/src/main/java/com/juick/http/www/PageTemplates.java b/src/main/java/com/juick/http/www/PageTemplates.java index 3eb66877..2ae945a5 100644 --- a/src/main/java/com/juick/http/www/PageTemplates.java +++ b/src/main/java/com/juick/http/www/PageTemplates.java @@ -22,6 +22,7 @@ import com.juick.Tag; import com.juick.server.Endpoints; import com.juick.server.MessagesQueries; import com.juick.server.UserQueries; +import org.rythmengine.Rythm; import org.springframework.jdbc.core.JdbcTemplate; import ru.sape.Sape; @@ -51,58 +52,43 @@ public class PageTemplates { private static String tagsHTML = null; public static void pageHead(PrintWriter out, String title, String headers) { - out.println("<!DOCTYPE html>"); - out.print("<html>"); - out.print("<head>"); - out.println("<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">"); - out.print("<link rel=\"stylesheet\" href=\"/style.css\"/>"); - out.print("<script type=\"text/javascript\" src=\"//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js\"></script>"); - out.print("<script type=\"text/javascript\" src=\"/scripts.js\"></script>"); - if (headers != null) { - out.print(headers); - } - out.print("<title>" + title + "</title>"); - out.println("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,user-scalable=no\"/>"); - out.println("<link rel=\"icon\" href=\"//i.juick.com/favicon.png\"/>"); - out.println("<!--[if lt IE 9 & (!IEMobile 7)]>"); - out.println("<script src=\"//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js\"></script>"); - out.println("<![endif]-->"); - out.println("</head>"); + out.print(Rythm.render("parts/page_header.html", headers, title)); out.flush(); out.println("<body>"); } public static void pageNavigation(PrintWriter out, com.juick.User visitor, String search) { - out.println("<header>"); - out.println(" <div id=\"logo\"><a href=\"/\">Juick</a></div>"); - out.print(" <nav id=\"global\"><ul>"); - out.print("<li><a href=\"/\">Популярные</a></li>"); - out.print("<li><a href=\"/?show=all\" rel=\"nofollow\">Все сообщения</a></li>"); - out.print("<li><a href=\"/?show=photos\" rel=\"nofollow\">Фотографии</a></li>"); - out.println("</ul></nav>"); - out.print(" <div id=\"search\"><form action=\"/\"><input type=\"text\" name=\"search\" class=\"text\" placeholder=\"Поиск\""); - if (search != null) { - out.print(" value=\"" + Utils.encodeHTML(search) + "\""); - } - out.println("/></form></div>"); - out.println(" <section id=\"headdiv\">"); - if (visitor != null) { - out.print(" <nav id=\"user\"><ul>"); - out.print("<li><a href=\"/?show=my\">Моя лента</a></li>"); - out.print("<li><a href=\"/pm/inbox\">Приватные</a></li>"); - out.print("<li><a href=\"/?show=discuss\">Обсуждения</a></li>"); - out.print("<li><a href=\"/?show=recommended\">Рекомендации</a></li>"); - out.println("</ul></nav>"); - out.print(" <nav id=\"actions\"><ul>"); - out.print("<li><a href=\"/#post\">Написать</a></li>"); - out.print("<li><a href=\"/" + visitor.getUName() + "\">@" + visitor.getUName() + "</a></li>"); - out.print("<li><a href=\"/logout\">Выйти</a></li>"); - out.println("</ul></nav>"); - } else { - out.println("<p>Чтобы добавлять сообщения и комментарии, <a href=\"#\" onclick=\"return openDialogLogin()\">представьтесь</a>.</p>"); - } - out.println(" </section>"); - out.println("</header>"); + out.print(Rythm.render("parts/page_navigation.html", search, visitor)); +// out.println("<header>"); +// out.println(" <div id=\"logo\"><a href=\"/\">Juick</a></div>"); +// out.print(" <nav id=\"global\"><ul>"); +// out.print("<li><a href=\"/\">Популярные</a></li>"); +// out.print("<li><a href=\"/?show=all\" rel=\"nofollow\">Все сообщения</a></li>"); +// out.print("<li><a href=\"/?show=photos\" rel=\"nofollow\">Фотографии</a></li>"); +// out.println("</ul></nav>"); +// out.print(" <div id=\"search\"><form action=\"/\"><input type=\"text\" name=\"search\" class=\"text\" placeholder=\"Поиск\""); +// if (search != null) { +// out.print(" value=\"" + Utils.encodeHTML(search) + "\""); +// } +// out.println("/></form></div>"); +// out.println(" <section id=\"headdiv\">"); +// if (visitor != null) { +// out.print(" <nav id=\"user\"><ul>"); +// out.print("<li><a href=\"/?show=my\">Моя лента</a></li>"); +// out.print("<li><a href=\"/pm/inbox\">Приватные</a></li>"); +// out.print("<li><a href=\"/?show=discuss\">Обсуждения</a></li>"); +// out.print("<li><a href=\"/?show=recommended\">Рекомендации</a></li>"); +// out.println("</ul></nav>"); +// out.print(" <nav id=\"actions\"><ul>"); +// out.print("<li><a href=\"/#post\">Написать</a></li>"); +// out.print("<li><a href=\"/" + visitor.getUName() + "\">@" + visitor.getUName() + "</a></li>"); +// out.print("<li><a href=\"/logout\">Выйти</a></li>"); +// out.println("</ul></nav>"); +// } else { +// out.println("<p>Чтобы добавлять сообщения и комментарии, <a href=\"#\" onclick=\"return openDialogLogin()\">представьтесь</a>.</p>"); +// } +// out.println(" </section>"); +// out.println("</header>"); } public static void pageYandexAd728(PrintWriter out, int YandexID) { diff --git a/src/main/java/com/juick/http/www/User.java b/src/main/java/com/juick/http/www/User.java index 1f2b7dc0..5d06045c 100644 --- a/src/main/java/com/juick/http/www/User.java +++ b/src/main/java/com/juick/http/www/User.java @@ -17,25 +17,21 @@ */ package com.juick.http.www; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.serializer.JSONSerializer; import com.juick.Tag; import com.juick.server.Endpoints; import com.juick.server.MessagesQueries; import com.juick.server.TagQueries; import com.juick.server.UserQueries; -import com.juick.xmpp.Stream; import org.apache.commons.lang3.tuple.Pair; +import org.json.JSONArray; +import org.json.JSONObject; import org.springframework.jdbc.core.JdbcTemplate; import java.io.IOException; import java.io.PrintWriter; -import java.io.UnsupportedEncodingException; import java.net.URLEncoder; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; import javax.servlet.ServletException; @@ -183,7 +179,7 @@ public class User { pageUserColumn(out, sql, user, visitor); out.println("<section id=\"content\">"); - out.println("<p>" + pageUserTags(sql, user, visitor, 0) + "</p>"); + out.println("<p>" + pageUserTags(sql, user, 0) + "</p>"); out.println("</section>"); PageTemplates.pageFooter(request, out, visitor, false); @@ -306,12 +302,16 @@ public class User { out.println(" <li><a href=\"./\">Блог</a></li>"); out.println(" <li><a href=\"./?show=recomm\" rel=\"nofollow\">Рекомендации</a></li>"); out.println(" <li><a href=\"./?show=photos\" rel=\"nofollow\">Фотографии</a></li>"); + if (visitor != null && visitor.getUID() == user.getUID()) { + out.println(" <li><a href=\"/?show=mycomments\" rel=\"nofollow\">Мои комментарии</a></li>"); + out.println(" <li><a href=\"/?show=unanswered\" rel=\"nofollow\">Неотвеченные</a></li>"); + } out.println(" </ul>"); out.println(" <hr/>"); out.println(" <form action=\"./\">"); out.println(" <p><input type=\"text\" name=\"search\" class=\"inp\" placeholder=\"Поиск\"/></p>"); out.println(" </form>"); - out.println(" <p class=\"tags\">" + pageUserTags(sql, user, visitor, 20) + "<a href=\"./tags\" rel=\"nofollow\">...</a></p>"); + out.println(" <p class=\"tags\">" + pageUserTags(sql, user, 20) + "<a href=\"./tags\" rel=\"nofollow\">...</a></p>"); out.println(" <hr/>"); out.println(" <div id=\"ustats\"><ul>"); out.println(" <li><a href=\"./friends\">Я читаю: " + UserQueries.getStatsIRead(sql, user.getUID()) + "</a></li>"); @@ -337,7 +337,28 @@ public class User { out.println("</aside>"); } - public static String pageUserTags(JdbcTemplate sql, com.juick.User user, com.juick.User visitor, int cnt) { + public static String pageUserTags(JdbcTemplate sql, com.juick.User user, int cnt) { + List<Tag> tags = getUserTags(sql, user, cnt); + + int maxUsageCnt = tags.size() > 0 ? tags.stream().mapToInt(tag -> tag.UsageCnt).max().getAsInt() : 1; + + return tags.stream().map(tag -> "<a href=\"./?tag=" + URLEncoder.encode(tag.Name) + + "\" title=\"" + tag.UsageCnt + "\" rel=\"nofollow\">" + tag.Name + "</a>") + .collect(Collectors.joining(" ")); + + /* + todo: + if (tags[i].UsageCnt > maxUsageCnt / 3 * 2) { + ret += "<big>" + tag + "</big> "; + } else if (tags[i].UsageCnt > maxUsageCnt / 3) { + ret += "<small>" + tag + "</small> "; + } else { + ret += tag + " "; + } + }*/ + } + + public static List<Tag> getUserTags(JdbcTemplate sql, com.juick.User user, int cnt) { List<Tag> tags; if (cnt > 0) { tags = sql.query("SELECT tags.name AS name,COUNT(DISTINCT messages_tags.message_id) AS cnt " + @@ -361,22 +382,22 @@ public class User { return t; }), user.getUID()); } + return tags; + } - int maxUsageCnt = tags.size() > 0 ? tags.stream().mapToInt(tag -> tag.UsageCnt).max().getAsInt() : 1; - - return tags.stream().map(tag -> "<a href=\"./?tag=" + URLEncoder.encode(tag.Name) + - "\" title=\"" + tag.UsageCnt + "\" rel=\"nofollow\">" + tag.Name + "</a>") - .collect(Collectors.joining(" ")); - - /* - todo: - if (tags[i].UsageCnt > maxUsageCnt / 3 * 2) { - ret += "<big>" + tag + "</big> "; - } else if (tags[i].UsageCnt > maxUsageCnt / 3) { - ret += "<small>" + tag + "</small> "; - } else { - ret += tag + " "; + public void doMyTagsJson(JdbcTemplate sql, HttpServletRequest request, HttpServletResponse response) throws IOException { + com.juick.User visitor = Utils.getVisitorUser(sql, request, response); + if (visitor != null) { + List<Tag> userTags = getUserTags(sql, visitor, 200); + JSONArray arr = new JSONArray(); + for (Tag userTag : userTags) { + arr.put(userTag.Name); } - }*/ + response.setContentType("text/json"); + response.setCharacterEncoding("UTF-8"); + response.getOutputStream().write(arr.toString().getBytes("UTF-8")); + } else { + response.sendError(404); + } } } diff --git a/src/main/java/com/juick/http/www/Utils.java b/src/main/java/com/juick/http/www/Utils.java index ab721020..16428842 100644 --- a/src/main/java/com/juick/http/www/Utils.java +++ b/src/main/java/com/juick/http/www/Utils.java @@ -162,6 +162,7 @@ public class Utils { } public static String encodeHTML(String str) { + if (str == null) return null; return str.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("'", "'").replaceAll("\"", """).replaceAll("\n", " "); } diff --git a/src/main/resources/templates/parts/page_header.html b/src/main/resources/templates/parts/page_header.html new file mode 100644 index 00000000..63087d6c --- /dev/null +++ b/src/main/resources/templates/parts/page_header.html @@ -0,0 +1,33 @@ +@args String headers, String title +<!DOCTYPE html> +<html> +<head> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <link rel="stylesheet" href="/style.css"/> + <link rel="stylesheet" href="/textext/textext.core.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.arrow.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.autocomplete.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.focus.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.prompt.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.tags.css"/> + <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script> + <script type="text/javascript" src="/textext/textext.core.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.ajax.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.arrow.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.autocomplete.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.filter.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.focus.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.prompt.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.suggestions.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.tags.js"></script> + <script type="text/javascript" src="/scripts.js"></script> + @if (headers != null) { + @raw() {@headers} + } + <title>@title</title> + <meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no"/> + <link rel="icon" href="//i.juick.com/favicon.png"/> + <!--[if lt IE 9 & (!IEMobile 7)]> + <script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js"></script> + <![endif]--> +</head> diff --git a/src/main/resources/templates/parts/page_navigation.html b/src/main/resources/templates/parts/page_navigation.html new file mode 100644 index 00000000..018818b0 --- /dev/null +++ b/src/main/resources/templates/parts/page_navigation.html @@ -0,0 +1,40 @@ +@args String search, com.juick.User visitor +<header> + <div id="logo"><a href="/">Juick</a></div> + <nav id="global"> + <ul> + <li><a href="/">Популярные</a></li> + <li><a href="/?show=all" rel="nofollow">Все сообщения</a></li> + <li><a href="/?show=photos" rel="nofollow">Фотографии</a></li> + </ul> + </nav> + <div id="search"> + <form action="/"><input type="text" name="search" class="text" placeholder="Поиск" + @if (search !=null) { + value="@search" + } + /></form> + </div> + <section id="headdiv"> + @if (visitor != null) { + <nav id="user"> + <ul> + <li><a href="/?show=my">Моя лента</a></li> + <li><a href="/pm/inbox">Приватные</a></li> + <li><a href="/?show=discuss">Обсуждения</a></li> + <li><a href="/?show=recommended">Рекомендации</a></li> + </ul> + </nav> + <nav id="actions"> + <ul> + <li><a href="/#post">Написать</a></li> + <li><a href="/@visitor.getUName()">@@ @visitor.getUName()</a></li> + <li><a href="/logout">Выйти</a></li> + </ul> + </nav> + } else { + <p>Чтобы добавлять сообщения и комментарии, <a href="#" onclick="return openDialogLogin()">представьтесь</a>. + </p> + } + </section> +</header> diff --git a/src/main/resources/templates/parts/post_form.html b/src/main/resources/templates/parts/post_form.html new file mode 100644 index 00000000..56184f55 --- /dev/null +++ b/src/main/resources/templates/parts/post_form.html @@ -0,0 +1,64 @@ +<form action="/post" method="post" enctype="multipart/form-data" onsubmit="return onsubmitNewMessage()"> + <section id="newmessage"> + <textarea name="body" placeholder="Новое сообщение..." + onclick="newmessage_toggleOpen()" + onkeypress="postformListener(this.form,event)"></textarea> + <div> + <input type="text" class="img" name="img" placeholder="Ссылка на изображение (JPG/PNG, до 10Мб)" style="margin-bottom: 8pt"/> или <a + href="#" onclick="return attachMessagePhoto(this)">загрузить</a><br/> + + <!--<textarea class="tags" name="tags" rows="1" id="post_tags" style="resize: none;" type="text"></textarea>--> + <input type="text" class="tags" name="tags" id="post_tags"/><br/> + <input type="submit" class="subm" value="Отправить"/> + </div> + <div id="newmessage_tags" style="padding-top: 10pt"> + <span style="display: none; border: 1px solid black; background-color: #1277aa; color: white; padding: 2pt 2pt; font-size: 9pt; margin: 2pt 2pt"> + sample + </span> + </div> + <script> + function newmessage_toggleOpen() { + $('#newmessage>div').css('display','block'); + $('#newmessage textarea').css('min-height','70px'); + + $.ajax({url:"/my_tags.json"}).done(function(data) { + $('#post_tags').textext({ + plugins: 'tags prompt focus autocomplete suggestions arrow', + tagsItems: [], + prompt: 'Теги (через ENTER)', + suggestions: data + }); + var plugin = $('#post_tags').textext()[0]; + var mtags = document.getElementById("newmessage_tags"); + var template = mtags.firstElementChild; + for(var i=0; i<data.length; i++) { + var nextItem = template.cloneNode(true); + nextItem.innerText = data[i]; + nextItem.style.display = "inline"; + var q = function(item) { + item.onclick = function() { + window.setTimeout(function() { + if (plugin.tags().getTagElement(item.innerText)) { + plugin.tags().removeTag(item.innerText); + } + plugin.tags().addTags([item.innerText]); + }, 50) + }; + item.onmouseover = function() { + item.style.fontStyle = "bold"; + item.style.backgroundColor = "#2ba4e3"; + }; + item.onmouseout = function() { + item.style.fontStyle = ""; + item.style.backgroundColor = template.style.backgroundColor; + }; + }; + q(nextItem); + mtags.appendChild(nextItem); + } + }); + newmessage_toggleOpen = function() {} + } + </script> + </section> +</form>
\ No newline at end of file diff --git a/src/main/resources/templates/parts/test.html b/src/main/resources/templates/parts/test.html new file mode 100644 index 00000000..95e5d093 --- /dev/null +++ b/src/main/resources/templates/parts/test.html @@ -0,0 +1,3 @@ + + +this is a test!!22
\ No newline at end of file diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 3bc3cf21..96302e3d 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -13,17 +13,25 @@ </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> - <url-pattern>/scripts.js</url-pattern> + <url-pattern>*.css</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>default</servlet-name> - <url-pattern>/style.css</url-pattern> + <url-pattern>*.js</url-pattern> + </servlet-mapping> + <servlet-mapping> + <servlet-name>default</servlet-name> + <url-pattern>*.testhtml</url-pattern> </servlet-mapping> <mime-mapping> <extension>js</extension> <mime-type>application/javascript;charset=UTF-8</mime-type> </mime-mapping> <mime-mapping> + <extension>testhtml</extension> + <mime-type>text/html;charset=UTF-8</mime-type> + </mime-mapping> + <mime-mapping> <extension>css</extension> <mime-type>text/css;charset=UTF-8</mime-type> </mime-mapping> diff --git a/src/main/webapp/scripts.js b/src/main/webapp/scripts.js index bb3ddc5d..811764e8 100644 --- a/src/main/webapp/scripts.js +++ b/src/main/webapp/scripts.js @@ -134,8 +134,9 @@ function postformListener(formEl,ev) { function unfoldPostForm() { if(window.location.pathname==="/" && window.location.hash==="#post") { - $('#newmessage>div').css('display','block'); - $('#newmessage textarea').css('min-height','70px'); + if (newmessage_toggleOpen) { + newmessage_toggleOpen(); + } $('#newmessage textarea')[0].focus(); } } @@ -652,6 +653,16 @@ jQuery.fn.selectText = function(){ })(jQuery); +// listening to style changes http://stackoverflow.com/questions/2157963/is-it-possible-to-listen-to-a-style-change-event +(function() { + var ev = new $.Event('style'), + orig = $.fn.css; + $.fn.css = function() { + $(this).trigger(ev); + return orig.apply(this, arguments); + } +})(); + /******************************************************************************/ $(document).ready(function() { diff --git a/src/main/webapp/style.css b/src/main/webapp/style.css index 69c7e2da..b247d178 100644 --- a/src/main/webapp/style.css +++ b/src/main/webapp/style.css @@ -48,7 +48,7 @@ body>header p { color: #000; font-size: 13pt; margin: 12px 0; text-align: center #newmessage { background: #E5E5E0; padding: 15px; margin-bottom: 20px; } #newmessage textarea { border: 1px solid #CCC; padding: 4px; width: 688px; resize: vertical; min-height: 14pt; height: 14pt; margin: 0 0 5px 0; } -#newmessage input { border: 1px solid #CCC; padding: 2px 4px; margin: 5px 0; } +#newmessage input { border: 1px solid #CCC; padding: 3px 6px 4px; /*margin: 5px 0;*/ } #newmessage>div { display: none; } #newmessage .img { width: 500px; } #newmessage .tags { width: 500px; } diff --git a/src/main/webapp/test.json b/src/main/webapp/test.json new file mode 100644 index 00000000..6df54a41 --- /dev/null +++ b/src/main/webapp/test.json @@ -0,0 +1,19 @@ +[ + "Basic", + "Closure", + "Cobol", + "Delphi", + "Erlang", + "Fortran", + "Go", + "Groovy", + "Haskel", + "Java", + "JavaScript", + "OCAML", + "PHP", + "Perl", + "Python", + "Ruby", + "Scala" +]
\ No newline at end of file diff --git a/src/main/webapp/test.testhtml b/src/main/webapp/test.testhtml new file mode 100644 index 00000000..e0e5092f --- /dev/null +++ b/src/main/webapp/test.testhtml @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Title</title> + <link rel="stylesheet" href="/style.css"/> + <link rel="stylesheet" href="/textext/textext.core.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.arrow.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.autocomplete.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.focus.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.prompt.css"/> + <link rel="stylesheet" href="/textext/textext.plugin.tags.css"/> + <script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script> + <script type="text/javascript" src="/textext/textext.core.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.ajax.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.arrow.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.autocomplete.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.filter.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.focus.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.prompt.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.suggestions.js"></script> + <script type="text/javascript" src="/textext/textext.plugin.tags.js"></script> +</head> +<body> + + <div> + xx8 + <input type="text" class="tags" name="tags" placeholder="" id="post_tags" style="width: 600px"/><br/> + </div> + <script> + $('#post_tags').textext({ + plugins : 'tags prompt focus autocomplete ajax arrow', + tagsItems : [], + prompt : 'Теги (через пробел)', + ajax : { + url : '/test.json', + dataType : 'json', + cacheResults : true + } + }); + </script> + + +</body> +</html>
\ No newline at end of file diff --git a/src/main/webapp/textext/textext.core.css b/src/main/webapp/textext/textext.core.css new file mode 100644 index 00000000..ad3fec05 --- /dev/null +++ b/src/main/webapp/textext/textext.core.css @@ -0,0 +1,29 @@ +.text-core { + position: relative; +} +.text-core .text-wrap { + background: #fff; + position: absolute; +} +.text-core .text-wrap textarea, +.text-core .text-wrap input { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-border-radius: 0px; + -moz-border-radius: 0px; + border-radius: 0px; + border: 1px solid #9daccc; + outline: none; + resize: none; + position: absolute; + z-index: 1; + background: none; + overflow: hidden; + margin: 0; + padding: 3px 5px 4px 5px; + white-space: nowrap; + font: 11px "lucida grande", tahoma, verdana, arial, sans-serif; + line-height: 13px; + height: auto; +} diff --git a/src/main/webapp/textext/textext.core.js b/src/main/webapp/textext/textext.core.js new file mode 100644 index 00000000..39cf4d3a --- /dev/null +++ b/src/main/webapp/textext/textext.core.js @@ -0,0 +1,1617 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($, undefined) +{ + /** + * TextExt is the main core class which by itself doesn't provide any functionality + * that is user facing, however it has the underlying mechanics to bring all the + * plugins together under one roof and make them work with each other or on their + * own. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt + */ + function TextExt() {}; + + /** + * ItemManager is used to seamlessly convert between string that come from the user input to whatever + * the format the item data is being passed around in. It's used by all plugins that in one way or + * another operate with items, such as Tags, Filter, Autocomplete and Suggestions. Default implementation + * works with `String` type. + * + * Each instance of `TextExt` creates a new instance of default implementation of `ItemManager` + * unless `itemManager` option was set to another implementation. + * + * To satisfy requirements of managing items of type other than a `String`, different implementation + * if `ItemManager` should be supplied. + * + * If you wish to bring your own implementation, you need to create a new class and implement all the + * methods that `ItemManager` has. After, you need to supply your pass via the `itemManager` option during + * initialization like so: + * + * $('#input').textext({ + * itemManager : CustomItemManager + * }) + * + * @author agorbatchev + * @date 2011/08/19 + * @id ItemManager + */ + function ItemManager() {}; + + /** + * TextExtPlugin is a base class for all plugins. It provides common methods which are reused + * by majority of plugins. + * + * All plugins must register themselves by calling the `$.fn.textext.addPlugin(name, constructor)` + * function while providing plugin name and constructor. The plugin name is the same name that user + * will identify the plugin in the `plugins` option when initializing TextExt component and constructor + * function will create a new instance of the plugin. *Without registering, the core won't + * be able to see the plugin.* + * + * <span class="new label version">new in 1.2.0</span> You can get instance of each plugin from the core + * via associated function with the same name as the plugin. For example: + * + * $('#input').textext()[0].tags() + * $('#input').textext()[0].autocomplete() + * ... + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin + */ + function TextExtPlugin() {}; + + var stringify = (JSON || {}).stringify, + slice = Array.prototype.slice, + p, + UNDEFINED = 'undefined', + + /** + * TextExt provides a way to pass in the options to configure the core as well as + * each plugin that is being currently used. The jQuery exposed plugin `$().textext()` + * function takes a hash object with key/value set of options. For example: + * + * $('textarea').textext({ + * enabled: true + * }) + * + * There are multiple ways of passing in the options: + * + * 1. Options could be nested multiple levels deep and accessed using all lowercased, dot + * separated style, eg `foo.bar.world`. The manual is using this style for clarity and + * consistency. For example: + * + * { + * item: { + * manager: ... + * }, + * + * html: { + * wrap: ... + * }, + * + * autocomplete: { + * enabled: ..., + * dropdown: { + * position: ... + * } + * } + * } + * + * 2. Options could be specified using camel cased names in a flat key/value fashion like so: + * + * { + * itemManager: ..., + * htmlWrap: ..., + * autocompleteEnabled: ..., + * autocompleteDropdownPosition: ... + * } + * + * 3. Finally, options could be specified in mixed style. It's important to understand that + * for each dot separated name, its alternative in camel case is also checked for, eg for + * `foo.bar.world` it's alternatives could be `fooBarWorld`, `foo.barWorld` or `fooBar.world`, + * which translates to `{ foo: { bar: { world: ... } } }`, `{ fooBarWorld: ... }`, + * `{ foo : { barWorld : ... } }` or `{ fooBar: { world: ... } }` respectively. For example: + * + * { + * itemManager : ..., + * htmlWrap: ..., + * autocomplete: { + * enabled: ..., + * dropdownPosition: ... + * } + * } + * + * Mixed case is used through out the code, wherever it seems appropriate. However in the code, all option + * names are specified in the dot notation because it works both ways where as camel case is not + * being converted to its alternative dot notation. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExt.options + */ + + /** + * Default instance of `ItemManager` which takes `String` type as default for tags. + * + * @name item.manager + * @default ItemManager + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.options.item.manager + */ + OPT_ITEM_MANAGER = 'item.manager', + + /** + * List of plugins that should be used with the current instance of TextExt. The list could be + * specified as array of strings or as comma or space separated string. + * + * @name plugins + * @default [] + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.options.plugins + */ + OPT_PLUGINS = 'plugins', + + /** + * TextExt allows for overriding of virtually any method that the core or any of its plugins + * use. This could be accomplished through the use of the `ext` option. + * + * It's possible to specifically target the core or any plugin, as well as overwrite all the + * desired methods everywhere. + * + * 1. Targeting the core: + * + * ext: { + * core: { + * trigger: function() + * { + * console.log('TextExt.trigger', arguments); + * $.fn.textext.TextExt.prototype.trigger.apply(this, arguments); + * } + * } + * } + * + * 2. Targeting individual plugins: + * + * ext: { + * tags: { + * addTags: function(tags) + * { + * console.log('TextExtTags.addTags', tags); + * $.fn.textext.TextExtTags.prototype.addTags.apply(this, arguments); + * } + * } + * } + * + * 3. Targeting `ItemManager` instance: + * + * ext: { + * itemManager: { + * stringToItem: function(str) + * { + * console.log('ItemManager.stringToItem', str); + * return $.fn.textext.ItemManager.prototype.stringToItem.apply(this, arguments); + * } + * } + * } + * + * 4. And finally, in edge cases you can extend everything at once: + * + * ext: { + * '*': { + * fooBar: function() {} + * } + * } + * + * @name ext + * @default {} + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.options.ext + */ + OPT_EXT = 'ext', + + /** + * HTML source that is used to generate elements necessary for the core and all other + * plugins to function. + * + * @name html.wrap + * @default '<div class="text-core"><div class="text-wrap"/></div>' + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.options.html.wrap + */ + OPT_HTML_WRAP = 'html.wrap', + + /** + * HTML source that is used to generate hidden input value of which will be submitted + * with the HTML form. + * + * @name html.hidden + * @default '<input type="hidden" />' + * @author agorbatchev + * @date 2011/08/20 + * @id TextExt.options.html.hidden + */ + OPT_HTML_HIDDEN = 'html.hidden', + + /** + * Hash table of key codes and key names for which special events will be created + * by the core. For each entry a `[name]KeyDown`, `[name]KeyUp` and `[name]KeyPress` events + * will be triggered along side with `anyKeyUp` and `anyKeyDown` events for every + * key stroke. + * + * Here's a list of default keys: + * + * { + * 8 : 'backspace', + * 9 : 'tab', + * 13 : 'enter!', + * 27 : 'escape!', + * 37 : 'left', + * 38 : 'up!', + * 39 : 'right', + * 40 : 'down!', + * 46 : 'delete', + * 108 : 'numpadEnter' + * } + * + * Please note the `!` at the end of some keys. This tells the core that by default + * this keypress will be trapped and not passed on to the text input. + * + * @name keys + * @default { ... } + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.options.keys + */ + OPT_KEYS = 'keys', + + /** + * The core triggers or reacts to the following events. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExt.events + */ + + /** + * Core triggers `preInvalidate` event before the dimensions of padding on the text input + * are set. + * + * @name preInvalidate + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.preInvalidate + */ + EVENT_PRE_INVALIDATE = 'preInvalidate', + + /** + * Core triggers `postInvalidate` event after the dimensions of padding on the text input + * are set. + * + * @name postInvalidate + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.postInvalidate + */ + EVENT_POST_INVALIDATE = 'postInvalidate', + + /** + * Core triggers `getFormData` on every key press to collect data that will be populated + * into the hidden input that will be submitted with the HTML form and data that will + * be displayed in the input field that user is currently interacting with. + * + * All plugins that wish to affect how the data is presented or sent must react to + * `getFormData` and populate the data in the following format: + * + * { + * input : {String}, + * form : {Object} + * } + * + * The data key must be a numeric weight which will be used to determine which data + * ends up being used. Data with the highest numerical weight gets the priority. This + * allows plugins to set the final data regardless of their initialization order, which + * otherwise would be impossible. + * + * For example, the Tags and Autocomplete plugins have to work side by side and Tags + * plugin must get priority on setting the data. Therefore the Tags plugin sets data + * with the weight 200 where as the Autocomplete plugin sets data with the weight 100. + * + * Here's an example of a typical `getFormData` handler: + * + * TextExtPlugin.prototype.onGetFormData = function(e, data, keyCode) + * { + * data[100] = self.formDataObject('input value', 'form value'); + * }; + * + * Core also reacts to the `getFormData` and updates hidden input with data which will be + * submitted with the HTML form. + * + * @name getFormData + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.getFormData + */ + EVENT_GET_FORM_DATA = 'getFormData', + + /** + * Core triggers and reacts to the `setFormData` event to update the actual value in the + * hidden input that will be submitted with the HTML form. Second argument can be value + * of any type and by default it will be JSON serialized with `TextExt.serializeData()` + * function. + * + * @name setFormData + * @author agorbatchev + * @date 2011/08/22 + * @id TextExt.events.setFormData + */ + EVENT_SET_FORM_DATA = 'setFormData', + + /** + * Core triggers and reacts to the `setInputData` event to update the actual value in the + * text input that user is interacting with. Second argument must be of a `String` type + * the value of which will be set into the text input. + * + * @name setInputData + * @author agorbatchev + * @date 2011/08/22 + * @id TextExt.events.setInputData + */ + EVENT_SET_INPUT_DATA = 'setInputData', + + /** + * Core triggers `postInit` event to let plugins run code after all plugins have been + * created and initialized. This is a good place to set some kind of global values before + * somebody gets to use them. This is not the right place to expect all plugins to finish + * their initialization. + * + * @name postInit + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.postInit + */ + EVENT_POST_INIT = 'postInit', + + /** + * Core triggers `ready` event after all global configuration and prepearation has been + * done and the TextExt component is ready for use. Event handlers should expect all + * values to be set and the plugins to be in the final state. + * + * @name ready + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.ready + */ + EVENT_READY = 'ready', + + /** + * Core triggers `anyKeyUp` event for every key up event triggered within the component. + * + * @name anyKeyUp + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.anyKeyUp + */ + + /** + * Core triggers `anyKeyDown` event for every key down event triggered within the component. + * + * @name anyKeyDown + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.anyKeyDown + */ + + /** + * Core triggers `[name]KeyUp` event for every key specifid in the `keys` option that is + * triggered within the component. + * + * @name [name]KeyUp + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.[name]KeyUp + */ + + /** + * Core triggers `[name]KeyDown` event for every key specified in the `keys` option that is + * triggered within the component. + * + * @name [name]KeyDown + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.[name]KeyDown + */ + + /** + * Core triggers `[name]KeyPress` event for every key specified in the `keys` option that is + * triggered within the component. + * + * @name [name]KeyPress + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.events.[name]KeyPress + */ + + DEFAULT_OPTS = { + itemManager : ItemManager, + + plugins : [], + ext : {}, + + html : { + wrap : '<div class="text-core"><div class="text-wrap"/></div>', + hidden : '<input type="hidden" />' + }, + + keys : { + 8 : 'backspace', + 9 : 'tab', + 13 : 'enter!', + 27 : 'escape!', + 37 : 'left', + 38 : 'up!', + 39 : 'right', + 40 : 'down!', + 46 : 'delete', + 108 : 'numpadEnter' + } + } + ; + + // Freak out if there's no JSON.stringify function found + if(!stringify) + throw new Error('JSON.stringify() not found'); + + /** + * Returns object property by name where name is dot-separated and object is multiple levels deep. + * @param target Object Source object. + * @param name String Dot separated property name, ie `foo.bar.world` + * @id core.getProperty + */ + function getProperty(source, name) + { + if(typeof(name) === 'string') + name = name.split('.'); + + var fullCamelCaseName = name.join('.').replace(/\.(\w)/g, function(match, letter) { return letter.toUpperCase() }), + nestedName = name.shift(), + result + ; + + if(typeof(result = source[fullCamelCaseName]) != UNDEFINED) + result = result; + + else if(typeof(result = source[nestedName]) != UNDEFINED && name.length > 0) + result = getProperty(result, name); + + // name.length here should be zero + return result; + }; + + /** + * Hooks up specified events in the scope of the current object. + * @author agorbatchev + * @date 2011/08/09 + */ + function hookupEvents() + { + var args = slice.apply(arguments), + self = this, + target = args.length === 1 ? self : args.shift(), + event + ; + + args = args[0] || {}; + + function bind(event, handler) + { + target.bind(event, function() + { + // apply handler to our PLUGIN object, not the target + return handler.apply(self, arguments); + }); + } + + for(event in args) + bind(event, args[event]); + }; + + function formDataObject(input, form) + { + return { 'input' : input, 'form' : form }; + }; + + //-------------------------------------------------------------------------------- + // ItemManager core component + + p = ItemManager.prototype; + + /** + * Initialization method called by the core during instantiation. + * + * @signature ItemManager.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/19 + * @id ItemManager.init + */ + p.init = function(core) + { + }; + + /** + * Filters out items from the list that don't match the query and returns remaining items. Default + * implementation checks if the item starts with the query. + * + * @signature ItemManager.filter(list, query) + * + * @param list {Array} List of items. Default implementation works with strings. + * @param query {String} Query string. + * + * @author agorbatchev + * @date 2011/08/19 + * @id ItemManager.filter + */ + p.filter = function(list, query) + { + var result = [], + i, item + ; + + for(i = 0; i < list.length; i++) + { + item = list[i]; + if(this.itemContains(item, query)) + result.push(item); + } + + return result; + }; + + /** + * Returns `true` if specified item contains another string, `false` otherwise. In the default implementation + * `String.indexOf()` is used to check if item string begins with the needle string. + * + * @signature ItemManager.itemContains(item, needle) + * + * @param item {Object} Item to check. Default implementation works with strings. + * @param needle {String} Search string to be found within the item. + * + * @author agorbatchev + * @date 2011/08/19 + * @id ItemManager.itemContains + */ + p.itemContains = function(item, needle) + { + return this.itemToString(item).toLowerCase().indexOf(needle.toLowerCase()) == 0; + }; + + /** + * Converts specified string to item. Because default implemenation works with string, input string + * is simply returned back. To use custom objects, different implementation of this method could + * return something like `{ name : {String} }`. + * + * @signature ItemManager.stringToItem(str) + * + * @param str {String} Input string. + * + * @author agorbatchev + * @date 2011/08/19 + * @id ItemManager.stringToItem + */ + p.stringToItem = function(str) + { + return str; + }; + + /** + * Converts specified item to string. Because default implemenation works with string, input string + * is simply returned back. To use custom objects, different implementation of this method could + * for example return `name` field of `{ name : {String} }`. + * + * @signature ItemManager.itemToString(item) + * + * @param item {Object} Input item to be converted to string. + * + * @author agorbatchev + * @date 2011/08/19 + * @id ItemManager.itemToString + */ + p.itemToString = function(item) + { + return item; + }; + + /** + * Returns `true` if both items are equal, `false` otherwise. Because default implemenation works with + * string, input items are compared as strings. To use custom objects, different implementation of this + * method could for example compare `name` fields of `{ name : {String} }` type object. + * + * @signature ItemManager.compareItems(item1, item2) + * + * @param item1 {Object} First item. + * @param item2 {Object} Second item. + * + * @author agorbatchev + * @date 2011/08/19 + * @id ItemManager.compareItems + */ + p.compareItems = function(item1, item2) + { + return item1 == item2; + }; + + //-------------------------------------------------------------------------------- + // TextExt core component + + p = TextExt.prototype; + + /** + * Initializes current component instance with work with the supplied text input and options. + * + * @signature TextExt.init(input, opts) + * + * @param input {HTMLElement} Text input. + * @param opts {Object} Options. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.init + */ + p.init = function(input, opts) + { + var self = this, + hiddenInput, + itemManager, + container + ; + + self._defaults = $.extend({}, DEFAULT_OPTS); + self._opts = opts || {}; + self._plugins = {}; + self._itemManager = itemManager = new (self.opts(OPT_ITEM_MANAGER))(); + input = $(input); + container = $(self.opts(OPT_HTML_WRAP)); + hiddenInput = $(self.opts(OPT_HTML_HIDDEN)); + + input + .wrap(container) + .keydown(function(e) { return self.onKeyDown(e) }) + .keyup(function(e) { return self.onKeyUp(e) }) + .data('textext', self) + ; + + // keep references to html elements using jQuery.data() to avoid circular references + $(self).data({ + 'hiddenInput' : hiddenInput, + 'wrapElement' : input.parents('.text-wrap').first(), + 'input' : input + }); + + // set the name of the hidden input to the text input's name + hiddenInput.attr('name', input.attr('name')); + // remove name attribute from the text input + input.attr('name', null); + // add hidden input to the DOM + hiddenInput.insertAfter(input); + + $.extend(true, itemManager, self.opts(OPT_EXT + '.item.manager')); + $.extend(true, self, self.opts(OPT_EXT + '.*'), self.opts(OPT_EXT + '.core')); + + self.originalWidth = input.outerWidth(); + + self.invalidateBounds(); + + itemManager.init(self); + + self.initPatches(); + self.initPlugins(self.opts(OPT_PLUGINS), $.fn.textext.plugins); + + self.on({ + setFormData : self.onSetFormData, + getFormData : self.onGetFormData, + setInputData : self.onSetInputData, + anyKeyUp : self.onAnyKeyUp + }); + + self.trigger(EVENT_POST_INIT); + self.trigger(EVENT_READY); + + self.getFormData(0); + }; + + /** + * Initialized all installed patches against current instance. The patches are initialized based on their + * initialization priority which is returned by each patch's `initPriority()` method. Priority + * is a `Number` where patches with higher value gets their `init()` method called before patches + * with lower priority value. + * + * This facilitates initializing of patches in certain order to insure proper dependencies + * regardless of which order they are loaded. + * + * By default all patches have the same priority - zero, which means they will be initialized + * in rorder they are loaded, that is unless `initPriority()` is overriden. + * + * @signature TextExt.initPatches() + * + * @author agorbatchev + * @date 2011/10/11 + * @id TextExt.initPatches + */ + p.initPatches = function() + { + var list = [], + source = $.fn.textext.patches, + name + ; + + for(name in source) + list.push(name); + + this.initPlugins(list, source); + }; + + /** + * Creates and initializes all specified plugins. The plugins are initialized based on their + * initialization priority which is returned by each plugin's `initPriority()` method. Priority + * is a `Number` where plugins with higher value gets their `init()` method called before plugins + * with lower priority value. + * + * This facilitates initializing of plugins in certain order to insure proper dependencies + * regardless of which order user enters them in the `plugins` option field. + * + * By default all plugins have the same priority - zero, which means they will be initialized + * in the same order as entered by the user. + * + * @signature TextExt.initPlugins(plugins) + * + * @param plugins {Array} List of plugin names to initialize. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.initPlugins + */ + p.initPlugins = function(plugins, source) + { + var self = this, + ext, name, plugin, initList = [], i + ; + + if(typeof(plugins) == 'string') + plugins = plugins.split(/\s*,\s*|\s+/g); + + for(i = 0; i < plugins.length; i++) + { + name = plugins[i]; + plugin = source[name]; + + if(plugin) + { + self._plugins[name] = plugin = new plugin(); + self[name] = (function(plugin) { + return function(){ return plugin; } + })(plugin); + initList.push(plugin); + $.extend(true, plugin, self.opts(OPT_EXT + '.*'), self.opts(OPT_EXT + '.' + name)); + } + } + + // sort plugins based on their priority values + initList.sort(function(p1, p2) + { + p1 = p1.initPriority(); + p2 = p2.initPriority(); + + return p1 === p2 + ? 0 + : p1 < p2 ? 1 : -1 + ; + }); + + for(i = 0; i < initList.length; i++) + initList[i].init(self); + }; + + /** + * Returns true if specified plugin is was instantiated for the current instance of core. + * + * @signature TextExt.hasPlugin(name) + * + * @param name {String} Name of the plugin to check. + * + * @author agorbatchev + * @date 2011/12/28 + * @id TextExt.hasPlugin + * @version 1.1 + */ + p.hasPlugin = function(name) + { + return !!this._plugins[name]; + }; + + /** + * Allows to add multiple event handlers which will be execued in the scope of the current object. + * + * @signature TextExt.on([target], handlers) + * + * @param target {Object} **Optional**. Target object which has traditional `bind(event, handler)` method. + * Handler function will still be executed in the current object's scope. + * @param handlers {Object} Key/value pairs of event names and handlers, eg `{ event: handler }`. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.on + */ + p.on = hookupEvents; + + /** + * Binds an event handler to the input box that user interacts with. + * + * @signature TextExt.bind(event, handler) + * + * @param event {String} Event name. + * @param handler {Function} Event handler. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.bind + */ + p.bind = function(event, handler) + { + this.input().bind(event, handler); + }; + + /** + * Triggers an event on the input box that user interacts with. All core events are originated here. + * + * @signature TextExt.trigger(event, ...args) + * + * @param event {String} Name of the event to trigger. + * @param ...args All remaining arguments will be passed to the event handler. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.trigger + */ + p.trigger = function() + { + var args = arguments; + this.input().trigger(args[0], slice.call(args, 1)); + }; + + /** + * Returns instance of `itemManager` that is used by the component. + * + * @signature TextExt.itemManager() + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.itemManager + */ + p.itemManager = function() + { + return this._itemManager; + }; + + /** + * Returns jQuery input element with which user is interacting with. + * + * @signature TextExt.input() + * + * @author agorbatchev + * @date 2011/08/10 + * @id TextExt.input + */ + p.input = function() + { + return $(this).data('input'); + }; + + /** + * Returns option value for the specified option by name. If the value isn't found in the user + * provided options, it will try looking for default value. + * + * @signature TextExt.opts(name) + * + * @param name {String} Option name as described in the options. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.opts + */ + p.opts = function(name) + { + var result = getProperty(this._opts, name); + return typeof(result) == 'undefined' ? getProperty(this._defaults, name) : result; + }; + + /** + * Returns HTML element that was created from the `html.wrap` option. This is the top level HTML + * container for the text input with which user is interacting with. + * + * @signature TextExt.wrapElement() + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.wrapElement + */ + p.wrapElement = function() + { + return $(this).data('wrapElement'); + }; + + /** + * Updates container to match dimensions of the text input. Triggers `preInvalidate` and `postInvalidate` + * events. + * + * @signature TextExt.invalidateBounds() + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.invalidateBounds + */ + p.invalidateBounds = function() + { + var self = this, + input = self.input(), + wrap = self.wrapElement(), + container = wrap.parent(), + width = self.originalWidth + 'px', + height + ; + + self.trigger(EVENT_PRE_INVALIDATE); + + height = input.outerHeight() + 'px'; + + // using css() method instead of width() and height() here because they don't seem to do the right thing in jQuery 1.8.x + // https://github.com/alexgorbatchev/jquery-textext/issues/74 + input.css({ 'width' : width }); + wrap.css({ 'width' : width, 'height' : height }); + container.css({ 'height' : height }); + + self.trigger(EVENT_POST_INVALIDATE); + }; + + /** + * Focuses user input on the text box. + * + * @signature TextExt.focusInput() + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.focusInput + */ + p.focusInput = function() + { + this.input()[0].focus(); + }; + + /** + * Serializes data for to be set into the hidden input field and which will be submitted + * with the HTML form. + * + * By default simple JSON serialization is used. It's expected that `JSON.stringify` + * method would be available either through built in class in most modern browsers + * or through JSON2 library. + * + * @signature TextExt.serializeData(data) + * + * @param data {Object} Data to serialize. + * + * @author agorbatchev + * @date 2011/08/09 + * @id TextExt.serializeData + */ + p.serializeData = stringify; + + /** + * Returns the hidden input HTML element which will be submitted with the HTML form. + * + * @signature TextExt.hiddenInput() + * + * @author agorbatchev + * @date 2011/08/09 + * @id TextExt.hiddenInput + */ + p.hiddenInput = function(value) + { + return $(this).data('hiddenInput'); + }; + + /** + * Abstracted functionality to trigger an event and get the data with maximum weight set by all + * the event handlers. This functionality is used for the `getFormData` event. + * + * @signature TextExt.getWeightedEventResponse(event, args) + * + * @param event {String} Event name. + * @param args {Object} Argument to be passed with the event. + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExt.getWeightedEventResponse + */ + p.getWeightedEventResponse = function(event, args) + { + var self = this, + data = {}, + maxWeight = 0 + ; + + self.trigger(event, data, args); + + for(var weight in data) + maxWeight = Math.max(maxWeight, weight); + + return data[maxWeight]; + }; + + /** + * Triggers the `getFormData` event to get all the plugins to return their data. + * + * After the data is returned, triggers `setFormData` and `setInputData` to update appopriate values. + * + * @signature TextExt.getFormData(keyCode) + * + * @param keyCode {Number} Key code number which has triggered this update. It's impotant to pass + * this value to the plugins because they might return different values based on the key that was + * pressed. For example, the Tags plugin returns an empty string for the `input` value if the enter + * key was pressed, otherwise it returns whatever is currently in the text input. + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExt.getFormData + */ + p.getFormData = function(keyCode) + { + var self = this, + data = self.getWeightedEventResponse(EVENT_GET_FORM_DATA, keyCode || 0) + ; + + self.trigger(EVENT_SET_FORM_DATA , data['form']); + self.trigger(EVENT_SET_INPUT_DATA , data['input']); + }; + + //-------------------------------------------------------------------------------- + // Event handlers + + /** + * Reacts to the `anyKeyUp` event and triggers the `getFormData` to change data that will be submitted + * with the form. Default behaviour is that everything that is typed in will be JSON serialized, so + * the end result will be a JSON string. + * + * @signature TextExt.onAnyKeyUp(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.onAnyKeyUp + */ + p.onAnyKeyUp = function(e, keyCode) + { + this.getFormData(keyCode); + }; + + /** + * Reacts to the `setInputData` event and populates the input text field that user is currently + * interacting with. + * + * @signature TextExt.onSetInputData(e, data) + * + * @param e {Event} jQuery event. + * @param data {String} Value to be set. + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExt.onSetInputData + */ + p.onSetInputData = function(e, data) + { + this.input().val(data); + }; + + /** + * Reacts to the `setFormData` event and populates the hidden input with will be submitted with + * the HTML form. The value will be serialized with `serializeData()` method. + * + * @signature TextExt.onSetFormData(e, data) + * + * @param e {Event} jQuery event. + * @param data {Object} Data that will be set. + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExt.onSetFormData + */ + p.onSetFormData = function(e, data) + { + var self = this; + self.hiddenInput().val(self.serializeData(data)); + }; + + /** + * Reacts to `getFormData` event triggered by the core. At the bare minimum the core will tell + * itself to use the current value in the text input as the data to be submitted with the HTML + * form. + * + * @signature TextExt.onGetFormData(e, data) + * + * @param e {Event} jQuery event. + * + * @author agorbatchev + * @date 2011/08/09 + * @id TextExt.onGetFormData + */ + p.onGetFormData = function(e, data) + { + var val = this.input().val(); + data[0] = formDataObject(val, val); + }; + + //-------------------------------------------------------------------------------- + // User mouse/keyboard input + + /** + * Triggers `[name]KeyUp` and `[name]KeyPress` for every keystroke as described in the events. + * + * @signature TextExt.onKeyUp(e) + * + * @param e {Object} jQuery event. + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.onKeyUp + */ + + /** + * Triggers `[name]KeyDown` for every keystroke as described in the events. + * + * @signature TextExt.onKeyDown(e) + * + * @param e {Object} jQuery event. + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.onKeyDown + */ + + $(['Down', 'Up']).each(function() + { + var type = this.toString(); + + p['onKey' + type] = function(e) + { + var self = this, + keyName = self.opts(OPT_KEYS)[e.keyCode], + defaultResult = true + ; + + if(keyName) + { + defaultResult = keyName.substr(-1) != '!'; + keyName = keyName.replace('!', ''); + + self.trigger(keyName + 'Key' + type); + + // manual *KeyPress event fimplementation for the function keys like Enter, Backspace, etc. + if(type == 'Up' && self._lastKeyDown == e.keyCode) + { + self._lastKeyDown = null; + self.trigger(keyName + 'KeyPress'); + } + + if(type == 'Down') + self._lastKeyDown = e.keyCode; + } + + self.trigger('anyKey' + type, e.keyCode); + + return defaultResult; + }; + }); + + //-------------------------------------------------------------------------------- + // Plugin Base + + p = TextExtPlugin.prototype; + + /** + * Allows to add multiple event handlers which will be execued in the scope of the current object. + * + * @signature TextExt.on([target], handlers) + * + * @param target {Object} **Optional**. Target object which has traditional `bind(event, handler)` method. + * Handler function will still be executed in the current object's scope. + * @param handlers {Object} Key/value pairs of event names and handlers, eg `{ event: handler }`. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin.on + */ + p.on = hookupEvents; + + /** + * Returns the hash object that `getFormData` triggered by the core expects. + * + * @signature TextExtPlugin.formDataObject(input, form) + * + * @param input {String} Value that will go into the text input that user is interacting with. + * @param form {Object} Value that will be serialized and put into the hidden that will be submitted + * with the HTML form. + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExtPlugin.formDataObject + */ + p.formDataObject = formDataObject; + + /** + * Initialization method called by the core during plugin instantiation. This method must be implemented + * by each plugin individually. + * + * @signature TextExtPlugin.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin.init + */ + p.init = function(core) { throw new Error('Not implemented') }; + + /** + * Initialization method wich should be called by the plugin during the `init()` call. + * + * @signature TextExtPlugin.baseInit(core, defaults) + * + * @param core {TextExt} Instance of the TextExt core class. + * @param defaults {Object} Default plugin options. These will be checked if desired value wasn't + * found in the options supplied by the user. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin.baseInit + */ + p.baseInit = function(core, defaults) + { + var self = this; + + core._defaults = $.extend(true, core._defaults, defaults); + self._core = core; + self._timers = {}; + }; + + /** + * Allows starting of multiple timeout calls. Each time this method is called with the same + * timer name, the timer is reset. This functionality is useful in cases where an action needs + * to occur only after a certain period of inactivity. For example, making an AJAX call after + * user stoped typing for 1 second. + * + * @signature TextExtPlugin.startTimer(name, delay, callback) + * + * @param name {String} Timer name. + * @param delay {Number} Delay in seconds. + * @param callback {Function} Callback function. + * + * @author agorbatchev + * @date 2011/08/25 + * @id TextExtPlugin.startTimer + */ + p.startTimer = function(name, delay, callback) + { + var self = this; + + self.stopTimer(name); + + self._timers[name] = setTimeout( + function() + { + delete self._timers[name]; + callback.apply(self); + }, + delay * 1000 + ); + }; + + /** + * Stops the timer by name without resetting it. + * + * @signature TextExtPlugin.stopTimer(name) + * + * @param name {String} Timer name. + * + * @author agorbatchev + * @date 2011/08/25 + * @id TextExtPlugin.stopTimer + */ + p.stopTimer = function(name) + { + clearTimeout(this._timers[name]); + }; + + /** + * Returns instance of the `TextExt` to which current instance of the plugin is attached to. + * + * @signature TextExtPlugin.core() + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin.core + */ + p.core = function() + { + return this._core; + }; + + /** + * Shortcut to the core's `opts()` method. Returns option value. + * + * @signature TextExtPlugin.opts(name) + * + * @param name {String} Option name as described in the options. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin.opts + */ + p.opts = function(name) + { + return this.core().opts(name); + }; + + /** + * Shortcut to the core's `itemManager()` method. Returns instance of the `ItemManger` that is + * currently in use. + * + * @signature TextExtPlugin.itemManager() + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin.itemManager + */ + p.itemManager = function() + { + return this.core().itemManager(); + }; + + /** + * Shortcut to the core's `input()` method. Returns instance of the HTML element that represents + * current text input. + * + * @signature TextExtPlugin.input() + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin.input + */ + p.input = function() + { + return this.core().input(); + }; + + /** + * Shortcut to the commonly used `this.input().val()` call to get or set value of the text input. + * + * @signature TextExtPlugin.val(value) + * + * @param value {String} Optional value. If specified, the value will be set, otherwise it will be + * returned. + * + * @author agorbatchev + * @date 2011/08/20 + * @id TextExtPlugin.val + */ + p.val = function(value) + { + var input = this.input(); + + if(typeof(value) === UNDEFINED) + return input.val(); + else + input.val(value); + }; + + /** + * Shortcut to the core's `trigger()` method. Triggers specified event with arguments on the + * component core. + * + * @signature TextExtPlugin.trigger(event, ...args) + * + * @param event {String} Name of the event to trigger. + * @param ...args All remaining arguments will be passed to the event handler. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtPlugin.trigger + */ + p.trigger = function() + { + var core = this.core(); + core.trigger.apply(core, arguments); + }; + + /** + * Shortcut to the core's `bind()` method. Binds specified handler to the event. + * + * @signature TextExtPlugin.bind(event, handler) + * + * @param event {String} Event name. + * @param handler {Function} Event handler. + * + * @author agorbatchev + * @date 2011/08/20 + * @id TextExtPlugin.bind + */ + p.bind = function(event, handler) + { + this.core().bind(event, handler); + }; + + /** + * Returns initialization priority for this plugin. If current plugin depends upon some other plugin + * to be initialized before or after, priority needs to be adjusted accordingly. Plugins with higher + * priority initialize before plugins with lower priority. + * + * Default initialization priority is `0`. + * + * @signature TextExtPlugin.initPriority() + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExtPlugin.initPriority + */ + p.initPriority = function() + { + return 0; + }; + + //-------------------------------------------------------------------------------- + // jQuery Integration + + /** + * TextExt integrates as a jQuery plugin available through the `$(selector).textext(opts)` call. If + * `opts` argument is passed, then a new instance of `TextExt` will be created for all the inputs + * that match the `selector`. If `opts` wasn't passed and TextExt was already intantiated for + * inputs that match the `selector`, array of `TextExt` instances will be returned instead. + * + * // will create a new instance of `TextExt` for all elements that match `.sample` + * $('.sample').textext({ ... }); + * + * // will return array of all `TextExt` instances + * var list = $('.sample').textext(); + * + * The following properties are also exposed through the jQuery `$.fn.textext`: + * + * * `TextExt` -- `TextExt` class. + * * `TextExtPlugin` -- `TextExtPlugin` class. + * * `ItemManager` -- `ItemManager` class. + * * `plugins` -- Key/value table of all registered plugins. + * * `addPlugin(name, constructor)` -- All plugins should register themselves using this function. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExt.jquery + */ + + var cssInjected = false; + + var textext = $.fn.textext = function(opts) + { + var css; + + if(!cssInjected && (css = $.fn.textext.css) != null) + { + $('head').append('<style>' + css + '</style>'); + cssInjected = true; + } + + return this.map(function() + { + var self = $(this); + + if(opts == null) + return self.data('textext'); + + var instance = new TextExt(); + + instance.init(self, opts); + self.data('textext', instance); + + return instance.input()[0]; + }); + }; + + /** + * This static function registers a new plugin which makes it available through the `plugins` option + * to the end user. The name specified here is the name the end user would put in the `plugins` option + * to add this plugin to a new instance of TextExt. + * + * @signature $.fn.textext.addPlugin(name, constructor) + * + * @param name {String} Name of the plugin. + * @param constructor {Function} Plugin constructor. + * + * @author agorbatchev + * @date 2011/10/11 + * @id TextExt.addPlugin + */ + textext.addPlugin = function(name, constructor) + { + textext.plugins[name] = constructor; + constructor.prototype = new textext.TextExtPlugin(); + }; + + /** + * This static function registers a new patch which is added to each instance of TextExt. If you are + * adding a new patch, make sure to call this method. + * + * @signature $.fn.textext.addPatch(name, constructor) + * + * @param name {String} Name of the patch. + * @param constructor {Function} Patch constructor. + * + * @author agorbatchev + * @date 2011/10/11 + * @id TextExt.addPatch + */ + textext.addPatch = function(name, constructor) + { + textext.patches[name] = constructor; + constructor.prototype = new textext.TextExtPlugin(); + }; + + textext.TextExt = TextExt; + textext.TextExtPlugin = TextExtPlugin; + textext.ItemManager = ItemManager; + textext.plugins = {}; + textext.patches = {}; +})(jQuery); + +(function($) +{ + function TextExtIE9Patches() {}; + + $.fn.textext.TextExtIE9Patches = TextExtIE9Patches; + $.fn.textext.addPatch('ie9',TextExtIE9Patches); + + var p = TextExtIE9Patches.prototype; + + p.init = function(core) + { + if(navigator.userAgent.indexOf('MSIE 9') == -1) + return; + + var self = this; + + core.on({ postInvalidate : self.onPostInvalidate }); + }; + + p.onPostInvalidate = function() + { + var self = this, + input = self.input(), + val = input.val() + ; + + // agorbatchev :: IE9 doesn't seem to update the padding if box-sizing is on until the + // text box value changes, so forcing this change seems to do the trick of updating + // IE's padding visually. + input.val(Math.random()); + input.val(val); + }; +})(jQuery); + diff --git a/src/main/webapp/textext/textext.plugin.ajax.js b/src/main/webapp/textext/textext.plugin.ajax.js new file mode 100644 index 00000000..073f46ab --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.ajax.js @@ -0,0 +1,354 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($) +{ + /** + * AJAX plugin is very useful if you want to load list of items from a data point and pass it + * to the Autocomplete or Filter plugins. + * + * Because it meant to be as a helper method for either Autocomplete or Filter plugin, without + * either of these two present AJAX plugin won't do anything. + * + * @author agorbatchev + * @date 2011/08/16 + * @id TextExtAjax + */ + function TextExtAjax() {}; + + $.fn.textext.TextExtAjax = TextExtAjax; + $.fn.textext.addPlugin('ajax', TextExtAjax); + + var p = TextExtAjax.prototype, + + /** + * AJAX plugin options are grouped under `ajax` when passed to the `$().textext()` function. Be + * mindful that the whole `ajax` object is also passed to jQuery `$.ajax` call which means that + * you can change all jQuery options as well. Please refer to the jQuery documentation on how + * to set url and all other parameters. For example: + * + * $('textarea').textext({ + * plugins: 'ajax', + * ajax: { + * url: 'http://...' + * } + * }) + * + * **Important**: Because it's necessary to pass options to `jQuery.ajax()` in a single object, + * all jQuery related AJAX options like `url`, `dataType`, etc **must** be within the `ajax` object. + * This is the exception to general rule that TextExt options can be specified in dot or camel case + * notation. + * + * @author agorbatchev + * @date 2011/08/16 + * @id TextExtAjax.options + */ + + /** + * By default, when user starts typing into the text input, AJAX plugin will start making requests + * to the `url` that you have specified and will pass whatever user has typed so far as a parameter + * named `q`, eg `?q=foo`. + * + * If you wish to change this behaviour, you can pass a function as a value for this option which + * takes one argument (the user input) and should return a key/value object that will be converted + * to the request parameters. For example: + * + * 'dataCallback' : function(query) + * { + * return { 'search' : query }; + * } + * + * @name ajax.data.callback + * @default null + * @author agorbatchev + * @date 2011/08/16 + * @id TextExtAjax.options.data.callback + */ + OPT_DATA_CALLBACK = 'ajax.data.callback', + + /** + * By default, the server end point is constantly being reloaded whenever user changes the value + * in the text input. If you'd rather have the client do result filtering, you can return all + * possible results from the server and cache them on the client by setting this option to `true`. + * + * In such a case, only one call to the server will be made and filtering will be performed on + * the client side using `ItemManager` attached to the core. + * + * @name ajax.data.results + * @default false + * @author agorbatchev + * @date 2011/08/16 + * @id TextExtAjax.options.cache.results + */ + OPT_CACHE_RESULTS = 'ajax.cache.results', + + /** + * The loading message delay is set in seconds and will specify how long it would take before + * user sees the message. If you don't want user to ever see this message, set the option value + * to `Number.MAX_VALUE`. + * + * @name ajax.loading.delay + * @default 0.5 + * @author agorbatchev + * @date 2011/08/16 + * @id TextExtAjax.options.loading.delay + */ + OPT_LOADING_DELAY = 'ajax.loading.delay', + + /** + * Whenever an AJAX request is made and the server takes more than the number of seconds specified + * in `ajax.loading.delay` to respond, the message specified in this option will appear in the drop + * down. + * + * @name ajax.loading.message + * @default "Loading..." + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAjax.options.loading.message + */ + OPT_LOADING_MESSAGE = 'ajax.loading.message', + + /** + * When user is typing in or otherwise changing the value of the text input, it's undesirable to make + * an AJAX request for every keystroke. Instead it's more conservative to send a request every number + * of seconds while user is typing the value. This number of seconds is specified by the `ajax.type.delay` + * option. + * + * @name ajax.type.delay + * @default 0.5 + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAjax.options.type.delay + */ + OPT_TYPE_DELAY = 'ajax.type.delay', + + /** + * AJAX plugin dispatches or reacts to the following events. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAjax.events + */ + + /** + * AJAX plugin reacts to the `getSuggestions` event dispatched by the Autocomplete plugin. + * + * @name getSuggestions + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAjax.events.getSuggestions + */ + + /** + * In the event of successful AJAX request, the AJAX coponent dispatches the `setSuggestions` + * event meant to be recieved by the Autocomplete plugin. + * + * @name setSuggestions + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAjax.events.setSuggestions + */ + EVENT_SET_SUGGESTION = 'setSuggestions', + + /** + * AJAX plugin dispatches the `showDropdown` event which Autocomplete plugin is expecting. + * This is used to temporarily show the loading message if the AJAX request is taking longer + * than expected. + * + * @name showDropdown + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAjax.events.showDropdown + */ + EVENT_SHOW_DROPDOWN = 'showDropdown', + + TIMER_LOADING = 'loading', + + DEFAULT_OPTS = { + ajax : { + typeDelay : 0.5, + loadingMessage : 'Loading...', + loadingDelay : 0.5, + cacheResults : false, + dataCallback : null + } + } + ; + + /** + * Initialization method called by the core during plugin instantiation. + * + * @signature TextExtAjax.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAjax.init + */ + p.init = function(core) + { + var self = this; + + self.baseInit(core, DEFAULT_OPTS); + + self.on({ + getSuggestions : self.onGetSuggestions + }); + + self._suggestions = null; + }; + + /** + * Performas an async AJAX with specified options. + * + * @signature TextExtAjax.load(query) + * + * @param query {String} Value that user has typed into the text area which is + * presumably the query. + * + * @author agorbatchev + * @date 2011/08/14 + * @id TextExtAjax.load + */ + p.load = function(query) + { + var self = this, + dataCallback = self.opts(OPT_DATA_CALLBACK) || function(query) { return { q : query } }, + opts + ; + + opts = $.extend(true, + { + data : dataCallback(query), + success : function(data) { self.onComplete(data, query) }, + error : function(jqXHR, message) { console.error(message, query) } + }, + self.opts('ajax') + ); + + $.ajax(opts); + }; + + /** + * Successful call AJAX handler. Takes the data that came back from AJAX and the + * original query that was used to make the call. + * + * @signature TextExtAjax.onComplete(data, query) + * + * @param data {Object} Data loaded from the server, should be an Array of strings + * by default or whatever data structure your custom `ItemManager` implements. + * + * @param query {String} Query string, ie whatever user has typed in. + * + * @author agorbatchev + * @date 2011/08/14 + * @id TextExtAjax.onComplete + */ + p.onComplete = function(data, query) + { + var self = this, + result = data + ; + + self.dontShowLoading(); + + // If results are expected to be cached, then we store the original + // data set and return the filtered one based on the original query. + // That means we do filtering on the client side, instead of the + // server side. + if(self.opts(OPT_CACHE_RESULTS) == true) + { + self._suggestions = data; + result = self.itemManager().filter(data, query); + } + + self.trigger(EVENT_SET_SUGGESTION, { result : result }); + }; + + /** + * If show loading message timer was started, calling this function disables it, + * otherwise nothing else happens. + * + * @signature TextExtAjax.dontShowLoading() + * + * @author agorbatchev + * @date 2011/08/16 + * @id TextExtAjax.dontShowLoading + */ + p.dontShowLoading = function() + { + this.stopTimer(TIMER_LOADING); + }; + + /** + * Shows message specified in `ajax.loading.message` if loading data takes more than + * number of seconds specified in `ajax.loading.delay`. + * + * @signature TextExtAjax.showLoading() + * + * @author agorbatchev + * @date 2011/08/15 + * @id TextExtAjax.showLoading + */ + p.showLoading = function() + { + var self = this; + + self.dontShowLoading(); + self.startTimer( + TIMER_LOADING, + self.opts(OPT_LOADING_DELAY), + function() + { + self.trigger(EVENT_SHOW_DROPDOWN, function(autocomplete) + { + autocomplete.clearItems(); + var node = autocomplete.addDropdownItem(self.opts(OPT_LOADING_MESSAGE)); + node.addClass('text-loading'); + }); + } + ); + }; + + /** + * Reacts to the `getSuggestions` event and begin loading suggestions. If + * `ajax.cache.results` is specified, all calls after the first one will use + * cached data and filter it with the `core.itemManager.filter()`. + * + * @signature TextExtAjax.onGetSuggestions(e, data) + * + * @param e {Object} jQuery event. + * @param data {Object} Data structure passed with the `getSuggestions` event + * which contains the user query, eg `{ query : "..." }`. + * + * @author agorbatchev + * @date 2011/08/15 + * @id TextExtAjax.onGetSuggestions + */ + p.onGetSuggestions = function(e, data) + { + var self = this, + suggestions = self._suggestions, + query = (data || {}).query || '' + ; + + if(suggestions && self.opts(OPT_CACHE_RESULTS) === true) + return self.onComplete(suggestions, query); + + self.startTimer( + 'ajax', + self.opts(OPT_TYPE_DELAY), + function() + { + self.showLoading(); + self.load(query); + } + ); + }; +})(jQuery); diff --git a/src/main/webapp/textext/textext.plugin.arrow.css b/src/main/webapp/textext/textext.plugin.arrow.css new file mode 100644 index 00000000..f5cea3dd --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.arrow.css @@ -0,0 +1,13 @@ +.text-core .text-wrap .text-arrow { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + position: absolute; + top: 0; + right: 0; + width: 22px; + height: 22px; + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAOAQMAAADHWqTrAAAAA3NCSVQICAjb4U/gAAAABlBMVEX///8yXJnt8Ns4AAAACXBIWXMAAAsSAAALEgHS3X78AAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1MzmNZGAwAAABpJREFUCJljYEAF/xsY6hkY7BgYZBgYOFBkADkdAmFDagYFAAAAAElFTkSuQmCC") 50% 50% no-repeat; + cursor: pointer; + z-index: 2; +} diff --git a/src/main/webapp/textext/textext.plugin.arrow.js b/src/main/webapp/textext/textext.plugin.arrow.js new file mode 100644 index 00000000..0531acb9 --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.arrow.js @@ -0,0 +1,106 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($) +{ + /** + * Displays a dropdown style arrow button. The `TextExtArrow` works together with the + * `TextExtAutocomplete` plugin and whenever clicked tells the autocomplete plugin to + * display its suggestions. + * + * @author agorbatchev + * @date 2011/12/27 + * @id TextExtArrow + */ + function TextExtArrow() {}; + + $.fn.textext.TextExtArrow = TextExtArrow; + $.fn.textext.addPlugin('arrow', TextExtArrow); + + var p = TextExtArrow.prototype, + /** + * Arrow plugin only has one option and that is its HTML template. It could be + * changed when passed to the `$().textext()` function. For example: + * + * $('textarea').textext({ + * plugins: 'arrow', + * html: { + * arrow: "<span/>" + * } + * }) + * + * @author agorbatchev + * @date 2011/12/27 + * @id TextExtArrow.options + */ + + /** + * HTML source that is used to generate markup required for the arrow. + * + * @name html.arrow + * @default '<div class="text-arrow"/>' + * @author agorbatchev + * @date 2011/12/27 + * @id TextExtArrow.options.html.arrow + */ + OPT_HTML_ARROW = 'html.arrow', + + DEFAULT_OPTS = { + html : { + arrow : '<div class="text-arrow"/>' + } + } + ; + + /** + * Initialization method called by the core during plugin instantiation. + * + * @signature TextExtArrow.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/12/27 + * @id TextExtArrow.init + */ + p.init = function(core) + { + var self = this, + arrow + ; + + self.baseInit(core, DEFAULT_OPTS); + + self._arrow = arrow = $(self.opts(OPT_HTML_ARROW)); + self.core().wrapElement().append(arrow); + arrow.bind('click', function(e) { self.onArrowClick(e); }); + }; + + //-------------------------------------------------------------------------------- + // Event handlers + + /** + * Reacts to the `click` event whenever user clicks the arrow. + * + * @signature TextExtArrow.onArrowClick(e) + * + * @param e {Object} jQuery event. + * @author agorbatchev + * @date 2011/12/27 + * @id TextExtArrow.onArrowClick + */ + p.onArrowClick = function(e) + { + this.trigger('toggleDropdown'); + this.core().focusInput(); + }; + + //-------------------------------------------------------------------------------- + // Core functionality + +})(jQuery); diff --git a/src/main/webapp/textext/textext.plugin.autocomplete.css b/src/main/webapp/textext/textext.plugin.autocomplete.css new file mode 100644 index 00000000..a72fcbde --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.autocomplete.css @@ -0,0 +1,35 @@ +.text-core .text-wrap .text-dropdown { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 0; + position: absolute; + z-index: 3; + background: #fff; + border: 1px solid #9daccc; + width: 100%; + max-height: 100px; + padding: 1px; + font: 11px "lucida grande", tahoma, verdana, arial, sans-serif; + display: none; + overflow-x: hidden; + overflow-y: auto; +} +.text-core .text-wrap .text-dropdown.text-position-below { + margin-top: 1px; +} +.text-core .text-wrap .text-dropdown.text-position-above { + margin-bottom: 1px; +} +.text-core .text-wrap .text-dropdown .text-list .text-suggestion { + padding: 3px 5px; + cursor: pointer; +} +.text-core .text-wrap .text-dropdown .text-list .text-suggestion em { + font-style: normal; + text-decoration: underline; +} +.text-core .text-wrap .text-dropdown .text-list .text-suggestion.text-selected { + color: #fff; + background: #6d84b4; +} diff --git a/src/main/webapp/textext/textext.plugin.autocomplete.js b/src/main/webapp/textext/textext.plugin.autocomplete.js new file mode 100644 index 00000000..fd493e4f --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.autocomplete.js @@ -0,0 +1,1110 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($) +{ + /** + * Autocomplete plugin brings the classic autocomplete functionality to the TextExt ecosystem. + * The gist of functionality is when user starts typing in, for example a term or a tag, a + * dropdown would be presented with possible suggestions to complete the input quicker. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete + */ + function TextExtAutocomplete() {}; + + $.fn.textext.TextExtAutocomplete = TextExtAutocomplete; + $.fn.textext.addPlugin('autocomplete', TextExtAutocomplete); + + var p = TextExtAutocomplete.prototype, + + CSS_DOT = '.', + CSS_SELECTED = 'text-selected', + CSS_DOT_SELECTED = CSS_DOT + CSS_SELECTED, + CSS_SUGGESTION = 'text-suggestion', + CSS_DOT_SUGGESTION = CSS_DOT + CSS_SUGGESTION, + CSS_LABEL = 'text-label', + CSS_DOT_LABEL = CSS_DOT + CSS_LABEL, + + /** + * Autocomplete plugin options are grouped under `autocomplete` when passed to the + * `$().textext()` function. For example: + * + * $('textarea').textext({ + * plugins: 'autocomplete', + * autocomplete: { + * dropdownPosition: 'above' + * } + * }) + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.options + */ + + /** + * This is a toggle switch to enable or disable the Autucomplete plugin. The value is checked + * each time at the top level which allows you to toggle this setting on the fly. + * + * @name autocomplete.enabled + * @default true + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.options.autocomplete.enabled + */ + OPT_ENABLED = 'autocomplete.enabled', + + /** + * This option allows to specify position of the dropdown. The two possible values + * are `above` and `below`. + * + * @name autocomplete.dropdown.position + * @default "below" + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.options.autocomplete.dropdown.position + */ + OPT_POSITION = 'autocomplete.dropdown.position', + + /** + * This option allows to specify maximum height of the dropdown. Value is taken directly, so + * if desired height is 200 pixels, value must be `200px`. + * + * @name autocomplete.dropdown.maxHeight + * @default "100px" + * @author agorbatchev + * @date 2011/12/29 + * @id TextExtAutocomplete.options.autocomplete.dropdown.maxHeight + * @version 1.1 + */ + OPT_MAX_HEIGHT = 'autocomplete.dropdown.maxHeight', + + /** + * This option allows to override how a suggestion item is rendered. The value should be + * a function, the first argument of which is suggestion to be rendered and `this` context + * is the current instance of `TextExtAutocomplete`. + * + * [Click here](/manual/examples/autocomplete-with-custom-render.html) to see a demo. + * + * For example: + * + * $('textarea').textext({ + * plugins: 'autocomplete', + * autocomplete: { + * render: function(suggestion) + * { + * return '<b>' + suggestion + '</b>'; + * } + * } + * }) + * + * @name autocomplete.render + * @default null + * @author agorbatchev + * @date 2011/12/23 + * @id TextExtAutocomplete.options.autocomplete.render + * @version 1.1 + */ + OPT_RENDER = 'autocomplete.render', + + /** + * HTML source that is used to generate the dropdown. + * + * @name html.dropdown + * @default '<div class="text-dropdown"><div class="text-list"/></div>' + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.options.html.dropdown + */ + OPT_HTML_DROPDOWN = 'html.dropdown', + + /** + * HTML source that is used to generate each suggestion. + * + * @name html.suggestion + * @default '<div class="text-suggestion"><span class="text-label"/></div>' + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.options.html.suggestion + */ + OPT_HTML_SUGGESTION = 'html.suggestion', + + /** + * Autocomplete plugin triggers or reacts to the following events. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.events + */ + + /** + * Autocomplete plugin triggers and reacts to the `hideDropdown` to hide the dropdown if it's + * already visible. + * + * @name hideDropdown + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.events.hideDropdown + */ + EVENT_HIDE_DROPDOWN = 'hideDropdown', + + /** + * Autocomplete plugin triggers and reacts to the `showDropdown` to show the dropdown if it's + * not already visible. + * + * It's possible to pass a render callback function which will be called instead of the + * default `TextExtAutocomplete.renderSuggestions()`. + * + * Here's how another plugin should trigger this event with the optional render callback: + * + * this.trigger('showDropdown', function(autocomplete) + * { + * autocomplete.clearItems(); + * var node = autocomplete.addDropdownItem('<b>Item</b>'); + * node.addClass('new-look'); + * }); + * + * @name showDropdown + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.events.showDropdown + */ + EVENT_SHOW_DROPDOWN = 'showDropdown', + + /** + * Autocomplete plugin reacts to the `setSuggestions` event triggered by other plugins which + * wish to populate the suggestion items. Suggestions should be passed as event argument in the + * following format: `{ data : [ ... ] }`. + * + * Here's how another plugin should trigger this event: + * + * this.trigger('setSuggestions', { data : [ "item1", "item2" ] }); + * + * @name setSuggestions + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.events.setSuggestions + */ + + /** + * Autocomplete plugin triggers the `getSuggestions` event and expects to get results by listening for + * the `setSuggestions` event. + * + * @name getSuggestions + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.events.getSuggestions + */ + EVENT_GET_SUGGESTIONS = 'getSuggestions', + + /** + * Autocomplete plugin triggers `getFormData` event with the current suggestion so that the the core + * will be updated with serialized data to be submitted with the HTML form. + * + * @name getFormData + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtAutocomplete.events.getFormData + */ + EVENT_GET_FORM_DATA = 'getFormData', + + /** + * Autocomplete plugin reacts to `toggleDropdown` event and either shows or hides the dropdown + * depending if it's currently hidden or visible. + * + * @name toggleDropdown + * @author agorbatchev + * @date 2011/12/27 + * @id TextExtAutocomplete.events.toggleDropdown + * @version 1.1 + */ + EVENT_TOGGLE_DROPDOWN = 'toggleDropdown', + + POSITION_ABOVE = 'above', + POSITION_BELOW = 'below', + + DATA_MOUSEDOWN_ON_AUTOCOMPLETE = 'mousedownOnAutocomplete', + + DEFAULT_OPTS = { + autocomplete : { + enabled : true, + dropdown : { + position : POSITION_BELOW, + maxHeight : '100px' + } + }, + + html : { + dropdown : '<div class="text-dropdown"><div class="text-list"/></div>', + suggestion : '<div class="text-suggestion"><span class="text-label"/></div>' + } + } + ; + + /** + * Initialization method called by the core during plugin instantiation. + * + * @signature TextExtAutocomplete.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.init + */ + p.init = function(core) + { + var self = this; + + self.baseInit(core, DEFAULT_OPTS); + + var input = self.input(), + container + ; + + if(self.opts(OPT_ENABLED) === true) + { + self.on({ + blur : self.onBlur, + anyKeyUp : self.onAnyKeyUp, + deleteKeyUp : self.onAnyKeyUp, + backspaceKeyPress : self.onBackspaceKeyPress, + enterKeyPress : self.onEnterKeyPress, + escapeKeyPress : self.onEscapeKeyPress, + setSuggestions : self.onSetSuggestions, + showDropdown : self.onShowDropdown, + hideDropdown : self.onHideDropdown, + toggleDropdown : self.onToggleDropdown, + postInvalidate : self.positionDropdown, + getFormData : self.onGetFormData, + + // using keyDown for up/down keys so that repeat events are + // captured and user can scroll up/down by holding the keys + downKeyDown : self.onDownKeyDown, + upKeyDown : self.onUpKeyDown + }); + + container = $(self.opts(OPT_HTML_DROPDOWN)); + container.insertAfter(input); + + self.on(container, { + mouseover : self.onMouseOver, + mousedown : self.onMouseDown, + click : self.onClick + }); + + container + .css('maxHeight', self.opts(OPT_MAX_HEIGHT)) + .addClass('text-position-' + self.opts(OPT_POSITION)) + ; + + $(self).data('container', container); + + $(document.body).click(function(e) + { + if (self.isDropdownVisible() && !self.withinWrapElement(e.target)) + self.trigger(EVENT_HIDE_DROPDOWN); + }); + + self.positionDropdown(); + } + }; + + /** + * Returns top level dropdown container HTML element. + * + * @signature TextExtAutocomplete.containerElement() + * + * @author agorbatchev + * @date 2011/08/15 + * @id TextExtAutocomplete.containerElement + */ + p.containerElement = function() + { + return $(this).data('container'); + }; + + //-------------------------------------------------------------------------------- + // User mouse/keyboard input + + /** + * Reacts to the `mouseOver` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onMouseOver(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onMouseOver + */ + p.onMouseOver = function(e) + { + var self = this, + target = $(e.target) + ; + + if(target.is(CSS_DOT_SUGGESTION)) + { + self.clearSelected(); + target.addClass(CSS_SELECTED); + } + }; + + /** + * Reacts to the `mouseDown` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onMouseDown(e) + * + * @param e {Object} jQuery event. + * + * @author adamayres + * @date 2012/01/13 + * @id TextExtAutocomplete.onMouseDown + */ + p.onMouseDown = function(e) + { + this.containerElement().data(DATA_MOUSEDOWN_ON_AUTOCOMPLETE, true); + }; + + /** + * Reacts to the `click` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onClick(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onClick + */ + p.onClick = function(e) + { + var self = this, + target = $(e.target) + ; + + if(target.is(CSS_DOT_SUGGESTION) || target.is(CSS_DOT_LABEL)) + self.trigger('enterKeyPress'); + + if (self.core().hasPlugin('tags')) + self.val(''); + }; + + /** + * Reacts to the `blur` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onBlur(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onBlur + */ + p.onBlur = function(e) + { + var self = this, + container = self.containerElement(), + isBlurByMousedown = container.data(DATA_MOUSEDOWN_ON_AUTOCOMPLETE) === true + ; + + // only trigger a close event if the blur event was + // not triggered by a mousedown event on the autocomplete + // otherwise set focus back back on the input + if(self.isDropdownVisible()) + isBlurByMousedown ? self.core().focusInput() : self.trigger(EVENT_HIDE_DROPDOWN); + + container.removeData(DATA_MOUSEDOWN_ON_AUTOCOMPLETE); + }; + + /** + * Reacts to the `backspaceKeyPress` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onBackspaceKeyPress(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onBackspaceKeyPress + */ + p.onBackspaceKeyPress = function(e) + { + var self = this, + isEmpty = self.val().length > 0 + ; + + if(isEmpty || self.isDropdownVisible()) + self.getSuggestions(); + }; + + /** + * Reacts to the `anyKeyUp` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onAnyKeyUp(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onAnyKeyUp + */ + p.onAnyKeyUp = function(e, keyCode) + { + var self = this, + isFunctionKey = self.opts('keys.' + keyCode) != null + ; + + if(self.val().length > 0 && !isFunctionKey) + self.getSuggestions(); + }; + + /** + * Reacts to the `downKeyDown` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onDownKeyDown(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onDownKeyDown + */ + p.onDownKeyDown = function(e) + { + var self = this; + + self.isDropdownVisible() + ? self.toggleNextSuggestion() + : self.getSuggestions() + ; + }; + + /** + * Reacts to the `upKeyDown` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onUpKeyDown(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onUpKeyDown + */ + p.onUpKeyDown = function(e) + { + this.togglePreviousSuggestion(); + }; + + /** + * Reacts to the `enterKeyPress` event triggered by the TextExt core. + * + * @signature TextExtAutocomplete.onEnterKeyPress(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onEnterKeyPress + */ + p.onEnterKeyPress = function(e) + { + var self = this; + + if(self.isDropdownVisible()) + self.selectFromDropdown(); + }; + + /** + * Reacts to the `escapeKeyPress` event triggered by the TextExt core. Hides the dropdown + * if it's currently visible. + * + * @signature TextExtAutocomplete.onEscapeKeyPress(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onEscapeKeyPress + */ + p.onEscapeKeyPress = function(e) + { + var self = this; + + if(self.isDropdownVisible()) + self.trigger(EVENT_HIDE_DROPDOWN); + }; + + //-------------------------------------------------------------------------------- + // Core functionality + + /** + * Positions dropdown either below or above the input based on the `autocomplete.dropdown.position` + * option specified, which could be either `above` or `below`. + * + * @signature TextExtAutocomplete.positionDropdown() + * + * @author agorbatchev + * @date 2011/08/15 + * @id TextExtAutocomplete.positionDropdown + */ + p.positionDropdown = function() + { + var self = this, + container = self.containerElement(), + direction = self.opts(OPT_POSITION), + height = self.core().wrapElement().outerHeight(), + css = {} + ; + + css[direction === POSITION_ABOVE ? 'bottom' : 'top'] = height + 'px'; + container.css(css); + }; + + /** + * Returns list of all the suggestion HTML elements in the dropdown. + * + * @signature TextExtAutocomplete.suggestionElements() + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.suggestionElements + */ + p.suggestionElements = function() + { + return this.containerElement().find(CSS_DOT_SUGGESTION); + }; + + + /** + * Highlights specified suggestion as selected in the dropdown. + * + * @signature TextExtAutocomplete.setSelectedSuggestion(suggestion) + * + * @param suggestion {Object} Suggestion object. With the default `ItemManager` this + * is expected to be a string, anything else with custom implementations. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.setSelectedSuggestion + */ + p.setSelectedSuggestion = function(suggestion) + { + if(!suggestion) + return; + + var self = this, + all = self.suggestionElements(), + target = all.first(), + item, i + ; + + self.clearSelected(); + + for(i = 0; i < all.length; i++) + { + item = $(all[i]); + + if(self.itemManager().compareItems(item.data(CSS_SUGGESTION), suggestion)) + { + target = item.addClass(CSS_SELECTED); + break; + } + } + + target.addClass(CSS_SELECTED); + self.scrollSuggestionIntoView(target); + }; + + /** + * Returns the first suggestion HTML element from the dropdown that is highlighted as selected. + * + * @signature TextExtAutocomplete.selectedSuggestionElement() + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.selectedSuggestionElement + */ + p.selectedSuggestionElement = function() + { + return this.suggestionElements().filter(CSS_DOT_SELECTED).first(); + }; + + /** + * Returns `true` if dropdown is currently visible, `false` otherwise. + * + * @signature TextExtAutocomplete.isDropdownVisible() + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.isDropdownVisible + */ + p.isDropdownVisible = function() + { + return this.containerElement().is(':visible') === true; + }; + + /** + * Reacts to the `getFormData` event triggered by the core. Returns data with the + * weight of 100 to be *less than the Tags plugin* data weight. The weights system is + * covered in greater detail in the [`getFormData`][1] event documentation. + * + * [1]: /manual/textext.html#getformdata + * + * @signature TextExtAutocomplete.onGetFormData(e, data, keyCode) + * + * @param e {Object} jQuery event. + * @param data {Object} Data object to be populated. + * @param keyCode {Number} Key code that triggered the original update request. + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExtAutocomplete.onGetFormData + */ + p.onGetFormData = function(e, data, keyCode) + { + var self = this, + val = self.val(), + inputValue = val, + formValue = val + ; + data[100] = self.formDataObject(inputValue, formValue); + }; + + /** + * Returns initialization priority of the Autocomplete plugin which is expected to be + * *greater than the Tags plugin* because of the dependencies. The value is 200. + * + * @signature TextExtAutocomplete.initPriority() + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExtAutocomplete.initPriority + */ + p.initPriority = function() + { + return 200; + }; + + /** + * Reacts to the `hideDropdown` event and hides the dropdown if it's already visible. + * + * @signature TextExtAutocomplete.onHideDropdown(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onHideDropdown + */ + p.onHideDropdown = function(e) + { + this.hideDropdown(); + }; + + /** + * Reacts to the 'toggleDropdown` event and shows or hides the dropdown depending if + * it's currently hidden or visible. + * + * @signature TextExtAutocomplete.onToggleDropdown(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/12/27 + * @id TextExtAutocomplete.onToggleDropdown + * @version 1.1.0 + */ + p.onToggleDropdown = function(e) + { + var self = this; + self.trigger(self.containerElement().is(':visible') ? EVENT_HIDE_DROPDOWN : EVENT_SHOW_DROPDOWN); + }; + + /** + * Reacts to the `showDropdown` event and shows the dropdown if it's not already visible. + * It's possible to pass a render callback function which will be called instead of the + * default `TextExtAutocomplete.renderSuggestions()`. + * + * If no suggestion were previously loaded, it will fire `getSuggestions` event and exit. + * + * Here's how another plugin should trigger this event with the optional render callback: + * + * this.trigger('showDropdown', function(autocomplete) + * { + * autocomplete.clearItems(); + * var node = autocomplete.addDropdownItem('<b>Item</b>'); + * node.addClass('new-look'); + * }); + * + * @signature TextExtAutocomplete.onShowDropdown(e, renderCallback) + * + * @param e {Object} jQuery event. + * @param renderCallback {Function} Optional callback function which would be used to + * render dropdown items. As a first argument, reference to the current instance of + * Autocomplete plugin will be supplied. It's assumed, that if this callback is provided + * rendering will be handled completely manually. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onShowDropdown + */ + p.onShowDropdown = function(e, renderCallback) + { + var self = this, + current = self.selectedSuggestionElement().data(CSS_SUGGESTION), + suggestions = self._suggestions + ; + + if(!suggestions) + return self.trigger(EVENT_GET_SUGGESTIONS); + + if($.isFunction(renderCallback)) + { + renderCallback(self); + } + else + { + self.renderSuggestions(self._suggestions); + self.toggleNextSuggestion(); + } + + self.showDropdown(self.containerElement()); + self.setSelectedSuggestion(current); + }; + + /** + * Reacts to the `setSuggestions` event. Expects to recieve the payload as the second argument + * in the following structure: + * + * { + * result : [ "item1", "item2" ], + * showHideDropdown : false + * } + * + * Notice the optional `showHideDropdown` option. By default, ie without the `showHideDropdown` + * value the method will trigger either `showDropdown` or `hideDropdown` depending if there are + * suggestions. If set to `false`, no event is triggered. + * + * @signature TextExtAutocomplete.onSetSuggestions(e, data) + * + * @param data {Object} Data payload. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.onSetSuggestions + */ + p.onSetSuggestions = function(e, data) + { + var self = this, + suggestions = self._suggestions = data.result + ; + + if(data.showHideDropdown !== false) + self.trigger(suggestions === null || suggestions.length === 0 ? EVENT_HIDE_DROPDOWN : EVENT_SHOW_DROPDOWN); + }; + + /** + * Prepears for and triggers the `getSuggestions` event with the `{ query : {String} }` as second + * argument. + * + * @signature TextExtAutocomplete.getSuggestions() + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.getSuggestions + */ + p.getSuggestions = function() + { + var self = this, + val = self.val() + ; + + if(self._previousInputValue == val) + return; + + // if user clears input, then we want to select first suggestion + // instead of the last one + if(val == '') + current = null; + + self._previousInputValue = val; + self.trigger(EVENT_GET_SUGGESTIONS, { query : val }); + }; + + /** + * Removes all HTML suggestion items from the dropdown. + * + * @signature TextExtAutocomplete.clearItems() + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.clearItems + */ + p.clearItems = function() + { + this.containerElement().find('.text-list').children().remove(); + }; + + /** + * Clears all and renders passed suggestions. + * + * @signature TextExtAutocomplete.renderSuggestions(suggestions) + * + * @param suggestions {Array} List of suggestions to render. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.renderSuggestions + */ + p.renderSuggestions = function(suggestions) + { + var self = this; + + self.clearItems(); + + $.each(suggestions || [], function(index, item) + { + self.addSuggestion(item); + }); + }; + + /** + * Shows the dropdown. + * + * @signature TextExtAutocomplete.showDropdown() + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.showDropdown + */ + p.showDropdown = function() + { + this.containerElement().show(); + }; + + /** + * Hides the dropdown. + * + * @signature TextExtAutocomplete.hideDropdown() + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.hideDropdown + */ + p.hideDropdown = function() + { + var self = this, + dropdown = self.containerElement() + ; + + self._previousInputValue = null; + dropdown.hide(); + }; + + /** + * Adds single suggestion to the bottom of the dropdown. Uses `ItemManager.itemToString()` to + * serialize provided suggestion to string. + * + * @signature TextExtAutocomplete.addSuggestion(suggestion) + * + * @param suggestion {Object} Suggestion item. By default expected to be a string. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.addSuggestion + */ + p.addSuggestion = function(suggestion) + { + var self = this, + renderer = self.opts(OPT_RENDER), + node = self.addDropdownItem(renderer ? renderer.call(self, suggestion) : self.itemManager().itemToString(suggestion)) + ; + + node.data(CSS_SUGGESTION, suggestion); + }; + + /** + * Adds and returns HTML node to the bottom of the dropdown. + * + * @signature TextExtAutocomplete.addDropdownItem(html) + * + * @param html {String} HTML to be inserted into the item. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.addDropdownItem + */ + p.addDropdownItem = function(html) + { + var self = this, + container = self.containerElement().find('.text-list'), + node = $(self.opts(OPT_HTML_SUGGESTION)) + ; + + node.find('.text-label').html(html); + container.append(node); + return node; + }; + + /** + * Removes selection highlight from all suggestion elements. + * + * @signature TextExtAutocomplete.clearSelected() + * + * @author agorbatchev + * @date 2011/08/02 + * @id TextExtAutocomplete.clearSelected + */ + p.clearSelected = function() + { + this.suggestionElements().removeClass(CSS_SELECTED); + }; + + /** + * Selects next suggestion relative to the current one. If there's no + * currently selected suggestion, it will select the first one. Selected + * suggestion will always be scrolled into view. + * + * @signature TextExtAutocomplete.toggleNextSuggestion() + * + * @author agorbatchev + * @date 2011/08/02 + * @id TextExtAutocomplete.toggleNextSuggestion + */ + p.toggleNextSuggestion = function() + { + var self = this, + selected = self.selectedSuggestionElement(), + next + ; + + if(selected.length > 0) + { + next = selected.next(); + + if(next.length > 0) + selected.removeClass(CSS_SELECTED); + } + else + { + next = self.suggestionElements().first(); + } + + next.addClass(CSS_SELECTED); + self.scrollSuggestionIntoView(next); + }; + + /** + * Selects previous suggestion relative to the current one. Selected + * suggestion will always be scrolled into view. + * + * @signature TextExtAutocomplete.togglePreviousSuggestion() + * + * @author agorbatchev + * @date 2011/08/02 + * @id TextExtAutocomplete.togglePreviousSuggestion + */ + p.togglePreviousSuggestion = function() + { + var self = this, + selected = self.selectedSuggestionElement(), + prev = selected.prev() + ; + + if(prev.length == 0) + return; + + self.clearSelected(); + prev.addClass(CSS_SELECTED); + self.scrollSuggestionIntoView(prev); + }; + + /** + * Scrolls specified HTML suggestion element into the view. + * + * @signature TextExtAutocomplete.scrollSuggestionIntoView(item) + * + * @param item {HTMLElement} jQuery HTML suggestion element which needs to + * scrolled into view. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.scrollSuggestionIntoView + */ + p.scrollSuggestionIntoView = function(item) + { + var itemHeight = item.outerHeight(), + dropdown = this.containerElement(), + dropdownHeight = dropdown.innerHeight(), + scrollPos = dropdown.scrollTop(), + itemTop = (item.position() || {}).top, + scrollTo = null, + paddingTop = parseInt(dropdown.css('paddingTop')) + ; + + if(itemTop == null) + return; + + // if scrolling down and item is below the bottom fold + if(itemTop + itemHeight > dropdownHeight) + scrollTo = itemTop + scrollPos + itemHeight - dropdownHeight + paddingTop; + + // if scrolling up and item is above the top fold + if(itemTop < 0) + scrollTo = itemTop + scrollPos - paddingTop; + + if(scrollTo != null) + dropdown.scrollTop(scrollTo); + }; + + /** + * Uses the value from the text input to finish autocomplete action. Currently selected + * suggestion from the dropdown will be used to complete the action. Triggers `hideDropdown` + * event. + * + * @signature TextExtAutocomplete.selectFromDropdown() + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtAutocomplete.selectFromDropdown + */ + p.selectFromDropdown = function() + { + var self = this, + suggestion = self.selectedSuggestionElement().data(CSS_SUGGESTION) + ; + + if(suggestion) + { + self.val(self.itemManager().itemToString(suggestion)); + self.core().getFormData(); + } + + self.trigger(EVENT_HIDE_DROPDOWN); + }; + + /** + * Determines if the specified HTML element is within the TextExt core wrap HTML element. + * + * @signature TextExtAutocomplete.withinWrapElement(element) + * + * @param element {HTMLElement} element to check if contained by wrap element + * + * @author adamayres + * @version 1.3.0 + * @date 2012/01/15 + * @id TextExtAutocomplete.withinWrapElement + */ + p.withinWrapElement = function(element) + { + return this.core().wrapElement().find(element).size() > 0; + } +})(jQuery); diff --git a/src/main/webapp/textext/textext.plugin.filter.js b/src/main/webapp/textext/textext.plugin.filter.js new file mode 100644 index 00000000..6f973e6e --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.filter.js @@ -0,0 +1,242 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($) +{ + /** + * The Filter plugin introduces ability to limit input that the text field + * will accept. If the Tags plugin is used, Filter plugin will limit which + * tags it's possible to add. + * + * The list of allowed items can be either specified through the + * options, can come from the Suggestions plugin or be loaded by the Ajax + * plugin. All these plugins have one thing in common -- they + * trigger `setSuggestions` event which the Filter plugin is expecting. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter + */ + function TextExtFilter() {}; + + $.fn.textext.TextExtFilter = TextExtFilter; + $.fn.textext.addPlugin('filter', TextExtFilter); + + var p = TextExtFilter.prototype, + + /** + * Filter plugin options are grouped under `filter` when passed to the + * `$().textext()` function. For example: + * + * $('textarea').textext({ + * plugins: 'filter', + * filter: { + * items: [ "item1", "item2" ] + * } + * }) + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter.options + */ + + /** + * This is a toggle switch to enable or disable the Filter plugin. The value is checked + * each time at the top level which allows you to toggle this setting on the fly. + * + * @name filter.enabled + * @default true + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter.options.enabled + */ + OPT_ENABLED = 'filter.enabled', + + /** + * Arra of items that the Filter plugin will allow the Tag plugin to add to the list of + * its resut tags. Each item by default is expected to be a string which default `ItemManager` + * can work with. You can change the item type by supplying custom `ItemManager`. + * + * @name filter.items + * @default null + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter.options.items + */ + OPT_ITEMS = 'filter.items', + + /** + * Filter plugin dispatches and reacts to the following events. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter.events + */ + + /** + * Filter plugin reacts to the `isTagAllowed` event triggered by the Tags plugin before + * adding a new tag to the list. If the new tag is among the `items` specified in options, + * then the new tag will be allowed. + * + * @name isTagAllowed + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter.events.isTagAllowed + */ + + /** + * Filter plugin reacts to the `setSuggestions` event triggered by other plugins like + * Suggestions and Ajax. + * + * However, event if this event is handled and items are passed with it and stored, if `items` + * option was supplied, it will always take precedense. + * + * @name setSuggestions + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter.events.setSuggestions + */ + + DEFAULT_OPTS = { + filter : { + enabled : true, + items : null + } + } + ; + + /** + * Initialization method called by the core during plugin instantiation. + * + * @signature TextExtFilter.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter.init + */ + p.init = function(core) + { + var self = this; + self.baseInit(core, DEFAULT_OPTS); + + self.on({ + getFormData : self.onGetFormData, + isTagAllowed : self.onIsTagAllowed, + setSuggestions : self.onSetSuggestions + }); + + self._suggestions = null; + }; + + //-------------------------------------------------------------------------------- + // Core functionality + + /** + * Reacts to the [`getFormData`][1] event triggered by the core. Returns data with the + * weight of 200 to be *greater than the Autocomplete plugins* data weights. + * The weights system is covered in greater detail in the [`getFormData`][1] event + * documentation. + * + * This method does nothing if Tags tag is also present. + * + * [1]: /manual/textext.html#getformdata + * + * @signature TextExtFilter.onGetFormData(e, data, keyCode) + * + * @param e {Object} jQuery event. + * @param data {Object} Data object to be populated. + * @param keyCode {Number} Key code that triggered the original update request. + * + * @author agorbatchev + * @date 2011/12/28 + * @id TextExtFilter.onGetFormData + * @version 1.1 + */ + p.onGetFormData = function(e, data, keyCode) + { + var self = this, + val = self.val(), + inputValue = val, + formValue = '' + ; + + if(!self.core().hasPlugin('tags')) + { + if(self.isValueAllowed(inputValue)) + formValue = val; + + data[300] = self.formDataObject(inputValue, formValue); + } + }; + + /** + * Checks given value if it's present in `filterItems` or was loaded for the Autocomplete + * or by the Suggestions plugins. `value` is compared to each item using `ItemManager.compareItems` + * method which is currently attached to the core. Returns `true` if value is known or + * Filter plugin is disabled. + * + * @signature TextExtFilter.isValueAllowed(value) + * + * @param value {Object} Value to check. + * + * @author agorbatchev + * @date 2011/12/28 + * @id TextExtFilter.isValueAllowed + * @version 1.1 + */ + p.isValueAllowed = function(value) + { + var self = this, + list = self.opts('filterItems') || self._suggestions || [], + itemManager = self.itemManager(), + result = !self.opts(OPT_ENABLED), // if disabled, should just return true + i + ; + + for(i = 0; i < list.length && !result; i++) + if(itemManager.compareItems(value, list[i])) + result = true; + + return result; + }; + + /** + * Handles `isTagAllowed` event dispatched by the Tags plugin. If supplied tag is not + * in the `items` list, method sets `result` on the `data` argument to `false`. + * + * @signature TextExtFilter.onIsTagAllowed(e, data) + * + * @param e {Object} jQuery event. + * @param data {Object} Payload in the following format : `{ tag : {Object}, result : {Boolean} }`. + * @author agorbatchev + * @date 2011/08/04 + * @id TextExtFilter.onIsTagAllowed + */ + p.onIsTagAllowed = function(e, data) + { + data.result = this.isValueAllowed(data.tag); + }; + + /** + * Reacts to the `setSuggestions` events and stores supplied suggestions for future use. + * + * @signature TextExtFilter.onSetSuggestions(e, data) + * + * @param e {Object} jQuery event. + * @param data {Object} Payload in the following format : `{ result : {Array} } }`. + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFilter.onSetSuggestions + */ + p.onSetSuggestions = function(e, data) + { + this._suggestions = data.result; + }; +})(jQuery); diff --git a/src/main/webapp/textext/textext.plugin.focus.css b/src/main/webapp/textext/textext.plugin.focus.css new file mode 100644 index 00000000..9579128b --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.focus.css @@ -0,0 +1,12 @@ +.text-core .text-wrap .text-focus { + -webkit-box-shadow: 0px 0px 6px #6d84b4; + -moz-box-shadow: 0px 0px 6px #6d84b4; + box-shadow: 0px 0px 6px #6d84b4; + position: absolute; + width: 100%; + height: 100%; + display: none; +} +.text-core .text-wrap .text-focus.text-show-focus { + display: block; +} diff --git a/src/main/webapp/textext/textext.plugin.focus.js b/src/main/webapp/textext/textext.plugin.focus.js new file mode 100644 index 00000000..5f7f3575 --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.focus.js @@ -0,0 +1,174 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($) +{ + /** + * Focus plugin displays a visual effect whenever user sets focus + * into the text area. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFocus + */ + function TextExtFocus() {}; + + $.fn.textext.TextExtFocus = TextExtFocus; + $.fn.textext.addPlugin('focus', TextExtFocus); + + var p = TextExtFocus.prototype, + /** + * Focus plugin only has one option and that is its HTML template. It could be + * changed when passed to the `$().textext()` function. For example: + * + * $('textarea').textext({ + * plugins: 'focus', + * html: { + * focus: "<span/>" + * } + * }) + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFocus.options + */ + + /** + * HTML source that is used to generate markup required for the focus effect. + * + * @name html.focus + * @default '<div class="text-focus"/>' + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFocus.options.html.focus + */ + OPT_HTML_FOCUS = 'html.focus', + + /** + * Focus plugin dispatches or reacts to the following events. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtFocus.events + */ + + /** + * Focus plugin reacts to the `focus` event and shows the markup generated from + * the `html.focus` option. + * + * @name focus + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFocus.events.focus + */ + + /** + * Focus plugin reacts to the `blur` event and hides the effect. + * + * @name blur + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFocus.events.blur + */ + + DEFAULT_OPTS = { + html : { + focus : '<div class="text-focus"/>' + } + } + ; + + /** + * Initialization method called by the core during plugin instantiation. + * + * @signature TextExtFocus.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtFocus.init + */ + p.init = function(core) + { + var self = this; + + self.baseInit(core, DEFAULT_OPTS); + self.core().wrapElement().append(self.opts(OPT_HTML_FOCUS)); + self.on({ + blur : self.onBlur, + focus : self.onFocus + }); + + self._timeoutId = 0; + }; + + //-------------------------------------------------------------------------------- + // Event handlers + + /** + * Reacts to the `blur` event and hides the focus effect with a slight delay which + * allows quick refocusing without effect blinking in and out. + * + * @signature TextExtFocus.onBlur(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtFocus.onBlur + */ + p.onBlur = function(e) + { + var self = this; + + clearTimeout(self._timeoutId); + + self._timeoutId = setTimeout(function() + { + self.getFocus().hide(); + }, + 100); + }; + + /** + * Reacts to the `focus` event and shows the focus effect. + * + * @signature TextExtFocus.onFocus + * + * @param e {Object} jQuery event. + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtFocus.onFocus + */ + p.onFocus = function(e) + { + var self = this; + + clearTimeout(self._timeoutId); + + self.getFocus().show(); + }; + + //-------------------------------------------------------------------------------- + // Core functionality + + /** + * Returns focus effect HTML element. + * + * @signature TextExtFocus.getFocus() + * + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtFocus.getFocus + */ + p.getFocus = function() + { + return this.core().wrapElement().find('.text-focus'); + }; +})(jQuery); diff --git a/src/main/webapp/textext/textext.plugin.prompt.css b/src/main/webapp/textext/textext.plugin.prompt.css new file mode 100644 index 00000000..49eab49b --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.prompt.css @@ -0,0 +1,16 @@ +.text-core .text-wrap .text-prompt { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + position: absolute; + width: 100%; + height: 100%; + margin: 1px 0 0 2px; + font: 11px "lucida grande", tahoma, verdana, arial, sans-serif; + color: #c0c0c0; + overflow: hidden; + white-space: pre; +} +.text-core .text-wrap .text-prompt.text-hide-prompt { + display: none; +} diff --git a/src/main/webapp/textext/textext.plugin.prompt.js b/src/main/webapp/textext/textext.plugin.prompt.js new file mode 100644 index 00000000..a40e71d8 --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.prompt.js @@ -0,0 +1,309 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($) +{ + /** + * Prompt plugin displays a visual user propmpt in the text input area. If user focuses + * on the input, the propt is hidden and only shown again when user focuses on another + * element and text input doesn't have a value. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtPrompt + */ + function TextExtPrompt() {}; + + $.fn.textext.TextExtPrompt = TextExtPrompt; + $.fn.textext.addPlugin('prompt', TextExtPrompt); + + var p = TextExtPrompt.prototype, + + CSS_HIDE_PROMPT = 'text-hide-prompt', + + /** + * Prompt plugin has options to change the prompt label and its HTML template. The options + * could be changed when passed to the `$().textext()` function. For example: + * + * $('textarea').textext({ + * plugins: 'prompt', + * prompt: 'Your email address' + * }) + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtPrompt.options + */ + + /** + * Prompt message that is displayed to the user whenever there's no value in the input. + * + * @name prompt + * @default 'Awaiting input...' + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtPrompt.options.prompt + */ + OPT_PROMPT = 'prompt', + + /** + * HTML source that is used to generate markup required for the prompt effect. + * + * @name html.prompt + * @default '<div class="text-prompt"/>' + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtPrompt.options.html.prompt + */ + OPT_HTML_PROMPT = 'html.prompt', + + /** + * Prompt plugin dispatches or reacts to the following events. + * @id TextExtPrompt.events + */ + + /** + * Prompt plugin reacts to the `focus` event and hides the markup generated from + * the `html.prompt` option. + * + * @name focus + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtPrompt.events.focus + */ + + /** + * Prompt plugin reacts to the `blur` event and shows the prompt back if user + * hasn't entered any value. + * + * @name blur + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtPrompt.events.blur + */ + + DEFAULT_OPTS = { + prompt : 'Awaiting input...', + + html : { + prompt : '<div class="text-prompt"/>' + } + } + ; + + /** + * Initialization method called by the core during plugin instantiation. + * + * @signature TextExtPrompt.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtPrompt.init + */ + p.init = function(core) + { + var self = this, + placeholderKey = 'placeholder', + container, + prompt + ; + + self.baseInit(core, DEFAULT_OPTS); + + container = $(self.opts(OPT_HTML_PROMPT)); + $(self).data('container', container); + + self.core().wrapElement().append(container); + self.setPrompt(self.opts(OPT_PROMPT)); + + prompt = core.input().attr(placeholderKey); + + if(!prompt) + prompt = self.opts(OPT_PROMPT); + + // clear placeholder attribute if set + core.input().attr(placeholderKey, ''); + + if(prompt) + self.setPrompt(prompt); + + if($.trim(self.val()).length > 0) + self.hidePrompt(); + + self.on({ + blur : self.onBlur, + focus : self.onFocus, + postInvalidate : self.onPostInvalidate, + postInit : self.onPostInit + }); + }; + + //-------------------------------------------------------------------------------- + // Event handlers + + /** + * Reacts to the `postInit` and configures the plugin for initial display. + * + * @signature TextExtPrompt.onPostInit(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/24 + * @id TextExtPrompt.onPostInit + */ + p.onPostInit = function(e) + { + this.invalidateBounds(); + }; + + /** + * Reacts to the `postInvalidate` and insures that prompt display remains correct. + * + * @signature TextExtPrompt.onPostInvalidate(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/24 + * @id TextExtPrompt.onPostInvalidate + */ + p.onPostInvalidate = function(e) + { + this.invalidateBounds(); + }; + + /** + * Repositions the prompt to make sure it's always at the same place as in the text input carret. + * + * @signature TextExtPrompt.invalidateBounds() + * + * @author agorbatchev + * @date 2011/08/24 + * @id TextExtPrompt.invalidateBounds + */ + p.invalidateBounds = function() + { + var self = this, + input = self.input() + ; + + self.containerElement().css({ + paddingLeft : input.css('paddingLeft'), + paddingTop : input.css('paddingTop') + }); + }; + + /** + * Reacts to the `blur` event and shows the prompt effect with a slight delay which + * allows quick refocusing without effect blinking in and out. + * + * The prompt is restored if the text box has no value. + * + * @signature TextExtPrompt.onBlur(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtPrompt.onBlur + */ + p.onBlur = function(e) + { + var self = this; + + self.startTimer('prompt', 0.1, function() + { + self.showPrompt(); + }); + }; + + /** + * Shows prompt HTML element. + * + * @signature TextExtPrompt.showPrompt() + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExtPrompt.showPrompt + */ + p.showPrompt = function() + { + var self = this, + input = self.input() + ; + + if($.trim(self.val()).length === 0 && !input.is(':focus')) + self.containerElement().removeClass(CSS_HIDE_PROMPT); + }; + + /** + * Hides prompt HTML element. + * + * @signature TextExtPrompt.hidePrompt() + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExtPrompt.hidePrompt + */ + p.hidePrompt = function() + { + this.stopTimer('prompt'); + this.containerElement().addClass(CSS_HIDE_PROMPT); + }; + + /** + * Reacts to the `focus` event and hides the prompt effect. + * + * @signature TextExtPrompt.onFocus + * + * @param e {Object} jQuery event. + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtPrompt.onFocus + */ + p.onFocus = function(e) + { + this.hidePrompt(); + }; + + //-------------------------------------------------------------------------------- + // Core functionality + + /** + * Sets the prompt display to the specified string. + * + * @signature TextExtPrompt.setPrompt(str) + * + * @oaram str {String} String that will be displayed in the prompt. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtPrompt.setPrompt + */ + p.setPrompt = function(str) + { + this.containerElement().text(str); + }; + + /** + * Returns prompt effect HTML element. + * + * @signature TextExtPrompt.containerElement() + * + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtPrompt.containerElement + */ + p.containerElement = function() + { + return $(this).data('container'); + }; +})(jQuery); diff --git a/src/main/webapp/textext/textext.plugin.suggestions.js b/src/main/webapp/textext/textext.plugin.suggestions.js new file mode 100644 index 00000000..1e04613b --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.suggestions.js @@ -0,0 +1,175 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($) +{ + /** + * Suggestions plugin allows to easily specify the list of suggestion items that the + * Autocomplete plugin would present to the user. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtSuggestions + */ + function TextExtSuggestions() {}; + + $.fn.textext.TextExtSuggestions = TextExtSuggestions; + $.fn.textext.addPlugin('suggestions', TextExtSuggestions); + + var p = TextExtSuggestions.prototype, + /** + * Suggestions plugin only has one option and that is to set suggestion items. It could be + * changed when passed to the `$().textext()` function. For example: + * + * $('textarea').textext({ + * plugins: 'suggestions', + * suggestions: [ "item1", "item2" ] + * }) + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtSuggestions.options + */ + + /** + * List of items that Autocomplete plugin would display in the dropdown. + * + * @name suggestions + * @default null + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtSuggestions.options.suggestions + */ + OPT_SUGGESTIONS = 'suggestions', + + /** + * Suggestions plugin dispatches or reacts to the following events. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtSuggestions.events + */ + + /** + * Suggestions plugin reacts to the `getSuggestions` event and returns `suggestions` items + * from the options. + * + * @name getSuggestions + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtSuggestions.events.getSuggestions + */ + + /** + * Suggestions plugin triggers the `setSuggestions` event to pass its own list of `Suggestions` + * to the Autocomplete plugin. + * + * @name setSuggestions + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtSuggestions.events.setSuggestions + */ + + /** + * Suggestions plugin reacts to the `postInit` event to pass its list of `suggestions` to the + * Autocomplete right away. + * + * @name postInit + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtSuggestions.events.postInit + */ + + DEFAULT_OPTS = { + suggestions : null + } + ; + + /** + * Initialization method called by the core during plugin instantiation. + * + * @signature TextExtSuggestions.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/18 + * @id TextExtSuggestions.init + */ + p.init = function(core) + { + var self = this; + + self.baseInit(core, DEFAULT_OPTS); + + self.on({ + getSuggestions : self.onGetSuggestions, + postInit : self.onPostInit + }); + }; + + /** + * Triggers `setSuggestions` and passes supplied suggestions to the Autocomplete plugin. + * + * @signature TextExtSuggestions.setSuggestions(suggestions, showHideDropdown) + * + * @param suggestions {Array} List of suggestions. With the default `ItemManager` it should + * be a list of strings. + * @param showHideDropdown {Boolean} If it's undesirable to show the dropdown right after + * suggestions are set, `false` should be passed for this argument. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtSuggestions.setSuggestions + */ + p.setSuggestions = function(suggestions, showHideDropdown) + { + this.trigger('setSuggestions', { result : suggestions, showHideDropdown : showHideDropdown != false }); + }; + + /** + * Reacts to the `postInit` event and triggers `setSuggestions` event to set suggestions list + * right after initialization. + * + * @signature TextExtSuggestions.onPostInit(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtSuggestions.onPostInit + */ + p.onPostInit = function(e) + { + var self = this; + self.setSuggestions(self.opts(OPT_SUGGESTIONS), false); + }; + + /** + * Reacts to the `getSuggestions` event and triggers `setSuggestions` event with the list + * of `suggestions` specified in the options. + * + * @signature TextExtSuggestions.onGetSuggestions(e, data) + * + * @param e {Object} jQuery event. + * @param data {Object} Payload from the `getSuggestions` event with the user query, eg `{ query: {String} }`. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtSuggestions.onGetSuggestions + */ + p.onGetSuggestions = function(e, data) + { + var self = this, + suggestions = self.opts(OPT_SUGGESTIONS) + ; + + suggestions.sort(); + self.setSuggestions(self.itemManager().filter(suggestions, data.query)); + }; +})(jQuery); diff --git a/src/main/webapp/textext/textext.plugin.tags.css b/src/main/webapp/textext/textext.plugin.tags.css new file mode 100644 index 00000000..bdbdcc43 --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.tags.css @@ -0,0 +1,49 @@ +.text-core .text-wrap .text-tags { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + position: absolute; + width: 100%; + height: 100%; + padding: 3px 35px 3px 3px; + cursor: text; +} +.text-core .text-wrap .text-tags.text-tags-on-top { + z-index: 2; +} +.text-core .text-wrap .text-tags .text-tag { + float: left; +} +.text-core .text-wrap .text-tags .text-tag .text-button { + -webkit-border-radius: 2px; + -moz-border-radius: 2px; + border-radius: 2px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + position: relative; + float: left; + border: 1px solid #9daccc; + background: #e2e6f0; + color: #000; + padding: 0px 17px 0px 3px; + margin: 0 2px 2px 0; + cursor: pointer; + height: 16px; + font: 11px "lucida grande", tahoma, verdana, arial, sans-serif; +} +.text-core .text-wrap .text-tags .text-tag .text-button a.text-remove { + position: absolute; + right: 3px; + top: 2px; + display: block; + width: 11px; + height: 11px; + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAAhCAYAAAAPm1F2AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAAB50RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNS4xqx9I6wAAAQ5JREFUOI2dlD0WwiAQhCc8L6HHgAPoASwtSYvX8BrQxtIyveYA8RppLO1jE+LwE8lzms2yH8MCj1QoaBzH+VuUYNYMS213UlvDRamtUbXb5ZyPHuDoxwGgip3ipfvGuGzPz+vZ/coDONdzFuYCO6ramQQG0DJIE1oPBBvM6e9LqaS2FwD7FWwnVoIAsOc2Xn1jDlyd8pfPBRVOBHA8cc/3yCmQqt0jcY4LuTyAF3pOYS6wI48LAm4MUrx5JthgSQJAt5LtNgAUgEMBBIC3AL2xgo58dEPfhE9wygef89FtCeC49UwltR1pQrK2qr9vNr7uRTCBF3pOYS6wI4/zdQ8MUpxPI9hgSQL0Xyio/QBt54DzsHQx6gAAAABJRU5ErkJggg==") 0 0 no-repeat; +} +.text-core .text-wrap .text-tags .text-tag .text-button a.text-remove:hover { + background-position: 0 -11px; +} +.text-core .text-wrap .text-tags .text-tag .text-button a.text-remove:active { + background-position: 0 -22px; +} diff --git a/src/main/webapp/textext/textext.plugin.tags.js b/src/main/webapp/textext/textext.plugin.tags.js new file mode 100644 index 00000000..3839badb --- /dev/null +++ b/src/main/webapp/textext/textext.plugin.tags.js @@ -0,0 +1,692 @@ +/** + * jQuery TextExt Plugin + * http://textextjs.com + * + * @version 1.3.1 + * @copyright Copyright (C) 2011 Alex Gorbatchev. All rights reserved. + * @license MIT License + */ +(function($) +{ + /** + * Tags plugin brings in the traditional tag functionality where user can assemble and + * edit list of tags. Tags plugin works especially well together with Autocomplete, Filter, + * Suggestions and Ajax plugins to provide full spectrum of features. It can also work on + * its own and just do one thing -- tags. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags + */ + function TextExtTags() {}; + + $.fn.textext.TextExtTags = TextExtTags; + $.fn.textext.addPlugin('tags', TextExtTags); + + var p = TextExtTags.prototype, + + CSS_DOT = '.', + CSS_TAGS_ON_TOP = 'text-tags-on-top', + CSS_DOT_TAGS_ON_TOP = CSS_DOT + CSS_TAGS_ON_TOP, + CSS_TAG = 'text-tag', + CSS_DOT_TAG = CSS_DOT + CSS_TAG, + CSS_TAGS = 'text-tags', + CSS_DOT_TAGS = CSS_DOT + CSS_TAGS, + CSS_LABEL = 'text-label', + CSS_DOT_LABEL = CSS_DOT + CSS_LABEL, + CSS_REMOVE = 'text-remove', + CSS_DOT_REMOVE = CSS_DOT + CSS_REMOVE, + + /** + * Tags plugin options are grouped under `tags` when passed to the + * `$().textext()` function. For example: + * + * $('textarea').textext({ + * plugins: 'tags', + * tags: { + * items: [ "tag1", "tag2" ] + * } + * }) + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.options + */ + + /** + * This is a toggle switch to enable or disable the Tags plugin. The value is checked + * each time at the top level which allows you to toggle this setting on the fly. + * + * @name tags.enabled + * @default true + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.options.tags.enabled + */ + OPT_ENABLED = 'tags.enabled', + + /** + * Allows to specify tags which will be added to the input by default upon initialization. + * Each item in the array must be of the type that current `ItemManager` can understand. + * Default type is `String`. + * + * @name tags.items + * @default null + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.options.tags.items + */ + OPT_ITEMS = 'tags.items', + + /** + * HTML source that is used to generate a single tag. + * + * @name html.tag + * @default '<div class="text-tags"/>' + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.options.html.tag + */ + OPT_HTML_TAG = 'html.tag', + + /** + * HTML source that is used to generate container for the tags. + * + * @name html.tags + * @default '<div class="text-tag"><div class="text-button"><span class="text-label"/><a class="text-remove"/></div></div>' + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.options.html.tags + */ + OPT_HTML_TAGS = 'html.tags', + + /** + * Tags plugin dispatches or reacts to the following events. + * + * @author agorbatchev + * @date 2011/08/17 + * @id TextExtTags.events + */ + + /** + * Tags plugin triggers the `isTagAllowed` event before adding each tag to the tag list. Other plugins have + * an opportunity to interrupt this by setting `result` of the second argument to `false`. For example: + * + * $('textarea').textext({...}).bind('isTagAllowed', function(e, data) + * { + * if(data.tag === 'foo') + * data.result = false; + * }) + * + * The second argument `data` has the following format: `{ tag : {Object}, result : {Boolean} }`. `tag` + * property is in the format that the current `ItemManager` can understand. + * + * @name isTagAllowed + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.events.isTagAllowed + */ + EVENT_IS_TAG_ALLOWED = 'isTagAllowed', + + /** + * Tags plugin triggers the `tagClick` event when user clicks on one of the tags. This allows to process + * the click and potentially change the value of the tag (for example in case of user feedback). + * + * $('textarea').textext({...}).bind('tagClick', function(e, tag, value, callback) + * { + * var newValue = window.prompt('New value', value); + + * if(newValue) + * callback(newValue, true); + * }) + * + * Callback argument has the following signature: + * + * function(newValue, refocus) + * { + * ... + * } + * + * Please check out [example](/manual/examples/tags-changing.html). + * + * @name tagClick + * @version 1.3.0 + * @author s.stok + * @date 2011/01/23 + * @id TextExtTags.events.tagClick + */ + EVENT_TAG_CLICK = 'tagClick', + + DEFAULT_OPTS = { + tags : { + enabled : true, + items : null + }, + + html : { + tags : '<div class="text-tags"/>', + tag : '<div class="text-tag"><div class="text-button"><span class="text-label"/><a class="text-remove"/></div></div>' + } + } + ; + + /** + * Initialization method called by the core during plugin instantiation. + * + * @signature TextExtTags.init(core) + * + * @param core {TextExt} Instance of the TextExt core class. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.init + */ + p.init = function(core) + { + this.baseInit(core, DEFAULT_OPTS); + var self = this, + input = self.input(), + container + ; + + if(self.opts(OPT_ENABLED)) + { + container = $(self.opts(OPT_HTML_TAGS)); + input.after(container); + + $(self).data('container', container); + + self.on({ + enterKeyPress : self.onEnterKeyPress, + backspaceKeyDown : self.onBackspaceKeyDown, + preInvalidate : self.onPreInvalidate, + postInit : self.onPostInit, + getFormData : self.onGetFormData + }); + + self.on(container, { + click : self.onClick, + mousemove : self.onContainerMouseMove + }); + + self.on(input, { + mousemove : self.onInputMouseMove + }); + } + + self._originalPadding = { + left : parseInt(input.css('paddingLeft') || 0), + top : parseInt(input.css('paddingTop') || 0) + }; + + self._paddingBox = { + left : 0, + top : 0 + }; + + self.updateFormCache(); + }; + + /** + * Returns HTML element in which all tag HTML elements are residing. + * + * @signature TextExtTags.containerElement() + * + * @author agorbatchev + * @date 2011/08/15 + * @id TextExtTags.containerElement + */ + p.containerElement = function() + { + return $(this).data('container'); + }; + + //-------------------------------------------------------------------------------- + // Event handlers + + /** + * Reacts to the `postInit` event triggered by the core and sets default tags + * if any were specified. + * + * @signature TextExtTags.onPostInit(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/09 + * @id TextExtTags.onPostInit + */ + p.onPostInit = function(e) + { + var self = this; + self.addTags(self.opts(OPT_ITEMS)); + }; + + /** + * Reacts to the [`getFormData`][1] event triggered by the core. Returns data with the + * weight of 200 to be *greater than the Autocomplete plugin* data weight. The weights + * system is covered in greater detail in the [`getFormData`][1] event documentation. + * + * [1]: /manual/textext.html#getformdata + * + * @signature TextExtTags.onGetFormData(e, data, keyCode) + * + * @param e {Object} jQuery event. + * @param data {Object} Data object to be populated. + * @param keyCode {Number} Key code that triggered the original update request. + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExtTags.onGetFormData + */ + p.onGetFormData = function(e, data, keyCode) + { + var self = this, + inputValue = keyCode === 13 ? '' : self.val(), + formValue = self._formData + ; + + data[200] = self.formDataObject(inputValue, formValue); + }; + + /** + * Returns initialization priority of the Tags plugin which is expected to be + * *less than the Autocomplete plugin* because of the dependencies. The value is + * 100. + * + * @signature TextExtTags.initPriority() + * + * @author agorbatchev + * @date 2011/08/22 + * @id TextExtTags.initPriority + */ + p.initPriority = function() + { + return 100; + }; + + /** + * Reacts to user moving mouse over the text area when cursor is over the text + * and not over the tags. Whenever mouse cursor is over the area covered by + * tags, the tags container is flipped to be on top of the text area which + * makes all tags functional with the mouse. + * + * @signature TextExtTags.onInputMouseMove(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtTags.onInputMouseMove + */ + p.onInputMouseMove = function(e) + { + this.toggleZIndex(e); + }; + + /** + * Reacts to user moving mouse over the tags. Whenever the cursor moves out + * of the tags and back into where the text input is happening visually, + * the tags container is sent back under the text area which allows user + * to interact with the text using mouse cursor as expected. + * + * @signature TextExtTags.onContainerMouseMove(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtTags.onContainerMouseMove + */ + p.onContainerMouseMove = function(e) + { + this.toggleZIndex(e); + }; + + /** + * Reacts to the `backspaceKeyDown` event. When backspace key is pressed in an empty text field, + * deletes last tag from the list. + * + * @signature TextExtTags.onBackspaceKeyDown(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/02 + * @id TextExtTags.onBackspaceKeyDown + */ + p.onBackspaceKeyDown = function(e) + { + var self = this, + lastTag = self.tagElements().last() + ; + + if(self.val().length == 0) + self.removeTag(lastTag); + }; + + /** + * Reacts to the `preInvalidate` event and updates the input box to look like the tags are + * positioned inside it. + * + * @signature TextExtTags.onPreInvalidate(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.onPreInvalidate + */ + p.onPreInvalidate = function(e) + { + var self = this, + lastTag = self.tagElements().last(), + pos = lastTag.position() + ; + + if(lastTag.length > 0) + pos.left += lastTag.innerWidth(); + else + pos = self._originalPadding; + + self._paddingBox = pos; + + self.input().css({ + paddingLeft : pos.left, + paddingTop : pos.top + }); + }; + + /** + * Reacts to the mouse `click` event. + * + * @signature TextExtTags.onClick(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.onClick + */ + p.onClick = function(e) + { + var self = this, + core = self.core(), + source = $(e.target), + focus = 0, + tag + ; + + if(source.is(CSS_DOT_TAGS)) + { + focus = 1; + } + else if(source.is(CSS_DOT_REMOVE)) + { + self.removeTag(source.parents(CSS_DOT_TAG + ':first')); + focus = 1; + } + else if(source.is(CSS_DOT_LABEL)) + { + tag = source.parents(CSS_DOT_TAG + ':first'); + self.trigger(EVENT_TAG_CLICK, tag, tag.data(CSS_TAG), tagClickCallback); + } + + function tagClickCallback(newValue, refocus) + { + tag.data(CSS_TAG, newValue); + tag.find(CSS_DOT_LABEL).text(self.itemManager().itemToString(newValue)); + + self.updateFormCache(); + core.getFormData(); + core.invalidateBounds(); + + if(refocus) + core.focusInput(); + } + + if(focus) + core.focusInput(); + }; + + /** + * Reacts to the `enterKeyPress` event and adds whatever is currently in the text input + * as a new tag. Triggers `isTagAllowed` to check if the tag could be added first. + * + * @signature TextExtTags.onEnterKeyPress(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.onEnterKeyPress + */ + p.onEnterKeyPress = function(e) + { + var self = this, + val = self.val(), + tag = self.itemManager().stringToItem(val) + ; + + if(self.isTagAllowed(tag)) + { + self.addTags([ tag ]); + // refocus the textarea just in case it lost the focus + self.core().focusInput(); + } + }; + + //-------------------------------------------------------------------------------- + // Core functionality + + /** + * Creates a cache object with all the tags currently added which will be returned + * in the `onGetFormData` handler. + * + * @signature TextExtTags.updateFormCache() + * + * @author agorbatchev + * @date 2011/08/09 + * @id TextExtTags.updateFormCache + */ + p.updateFormCache = function() + { + var self = this, + result = [] + ; + + self.tagElements().each(function() + { + result.push($(this).data(CSS_TAG)); + }); + + // cache the results to be used in the onGetFormData + self._formData = result; + }; + + /** + * Toggles tag container to be on top of the text area or under based on where + * the mouse cursor is located. When cursor is above the text input and out of + * any of the tags, the tags container is sent under the text area. If cursor + * is over any of the tags, the tag container is brought to be over the text + * area. + * + * @signature TextExtTags.toggleZIndex(e) + * + * @param e {Object} jQuery event. + * + * @author agorbatchev + * @date 2011/08/08 + * @id TextExtTags.toggleZIndex + */ + p.toggleZIndex = function(e) + { + var self = this, + offset = self.input().offset(), + mouseX = e.clientX - offset.left, + mouseY = e.clientY - offset.top, + box = self._paddingBox, + container = self.containerElement(), + isOnTop = container.is(CSS_DOT_TAGS_ON_TOP), + isMouseOverText = mouseX > box.left && mouseY > box.top + ; + + if(!isOnTop && !isMouseOverText || isOnTop && isMouseOverText) + container[(!isOnTop ? 'add' : 'remove') + 'Class'](CSS_TAGS_ON_TOP); + }; + + /** + * Returns all tag HTML elements. + * + * @signature TextExtTags.tagElements() + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.tagElements + */ + p.tagElements = function() + { + return this.containerElement().find(CSS_DOT_TAG); + }; + + /** + * Wrapper around the `isTagAllowed` event which triggers it and returns `true` + * if `result` property of the second argument remains `true`. + * + * @signature TextExtTags.isTagAllowed(tag) + * + * @param tag {Object} Tag object that the current `ItemManager` can understand. + * Default is `String`. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.isTagAllowed + */ + p.isTagAllowed = function(tag) + { + var opts = { tag : tag, result : true }; + this.trigger(EVENT_IS_TAG_ALLOWED, opts); + return opts.result === true; + }; + + /** + * Adds specified tags to the tag list. Triggers `isTagAllowed` event for each tag + * to insure that it could be added. Calls `TextExt.getFormData()` to refresh the data. + * + * @signature TextExtTags.addTags(tags) + * + * @param tags {Array} List of tags that current `ItemManager` can understand. Default + * is `String`. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.addTags + */ + p.addTags = function(tags) + { + if(!tags || tags.length == 0) + return; + + var self = this, + core = self.core(), + container = self.containerElement(), + i, tag + ; + + for(i = 0; i < tags.length; i++) + { + tag = tags[i]; + + if(tag && self.isTagAllowed(tag)) + container.append(self.renderTag(tag)); + } + + self.updateFormCache(); + core.getFormData(); + core.invalidateBounds(); + }; + + /** + * Returns HTML element for the specified tag. + * + * @signature TextExtTags.getTagElement(tag) + * + * @param tag {Object} Tag object in the format that current `ItemManager` can understand. + * Default is `String`. + + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.getTagElement + */ + p.getTagElement = function(tag) + { + var self = this, + list = self.tagElements(), + i, item + ; + + for(i = 0; i < list.length; i++) { + item = $(list[i]); + if (self.itemManager().compareItems(item.data(CSS_TAG), tag)) + return item; + } + }; + + /** + * Removes specified tag from the list. Calls `TextExt.getFormData()` to refresh the data. + * + * @signature TextExtTags.removeTag(tag) + * + * @param tag {Object} Tag object in the format that current `ItemManager` can understand. + * Default is `String`. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.removeTag + */ + p.removeTag = function(tag) + { + var self = this, + core = self.core(), + element + ; + + if(tag instanceof $) + { + element = tag; + tag = tag.data(CSS_TAG); + } + else + { + element = self.getTagElement(tag); + } + + element.remove(); + self.updateFormCache(); + core.getFormData(); + core.invalidateBounds(); + }; + + /** + * Creates and returns new HTML element from the source code specified in the `html.tag` option. + * + * @signature TextExtTags.renderTag(tag) + * + * @param tag {Object} Tag object in the format that current `ItemManager` can understand. + * Default is `String`. + * + * @author agorbatchev + * @date 2011/08/19 + * @id TextExtTags.renderTag + */ + p.renderTag = function(tag) + { + var self = this, + node = $(self.opts(OPT_HTML_TAG)) + ; + + node.find('.text-label').text(self.itemManager().itemToString(tag)); + node.data(CSS_TAG, tag); + return node; + }; +})(jQuery); |