From f0b0f57643edf002548b9962fcf63e578605af34 Mon Sep 17 00:00:00 2001 From: Tamas Kocsis Date: Mon, 21 Sep 2020 18:23:28 +0200 Subject: [PATCH] UiFileManager plugin --- plugins/UiFileManager/UiFileManagerPlugin.py | 84 ++ plugins/UiFileManager/__init__.py | 1 + plugins/UiFileManager/languages/hu.json | 20 + plugins/UiFileManager/media/css/Menu.css | 33 + plugins/UiFileManager/media/css/Selectbar.css | 17 + .../UiFileManager/media/css/UiFileManager.css | 148 ++++ plugins/UiFileManager/media/img/loading.gif | Bin 0 -> 723 bytes .../UiFileManager/media/js/FileEditor.coffee | 171 ++++ .../media/js/FileItemList.coffee | 194 +++++ .../UiFileManager/media/js/FileList.coffee | 270 ++++++ .../media/js/UiFileManager.coffee | 79 ++ .../media/js/lib/Animation.coffee | 138 ++++ .../UiFileManager/media/js/lib/Class.coffee | 23 + .../UiFileManager/media/js/lib/Dollar.coffee | 3 + .../media/js/lib/ItemList.coffee | 26 + .../UiFileManager/media/js/lib/Menu.coffee | 110 +++ .../UiFileManager/media/js/lib/Promise.coffee | 74 ++ .../media/js/lib/Prototypes.coffee | 9 + .../media/js/lib/RateLimitCb.coffee | 62 ++ .../UiFileManager/media/js/lib/Text.coffee | 147 ++++ .../UiFileManager/media/js/lib/Time.coffee | 59 ++ .../media/js/lib/ZeroFrame.coffee | 85 ++ .../UiFileManager/media/js/lib/maquette.js | 770 ++++++++++++++++++ plugins/UiFileManager/media/list.html | 18 + 24 files changed, 2541 insertions(+) create mode 100644 plugins/UiFileManager/UiFileManagerPlugin.py create mode 100644 plugins/UiFileManager/__init__.py create mode 100644 plugins/UiFileManager/languages/hu.json create mode 100644 plugins/UiFileManager/media/css/Menu.css create mode 100644 plugins/UiFileManager/media/css/Selectbar.css create mode 100644 plugins/UiFileManager/media/css/UiFileManager.css create mode 100644 plugins/UiFileManager/media/img/loading.gif create mode 100644 plugins/UiFileManager/media/js/FileEditor.coffee create mode 100644 plugins/UiFileManager/media/js/FileItemList.coffee create mode 100644 plugins/UiFileManager/media/js/FileList.coffee create mode 100644 plugins/UiFileManager/media/js/UiFileManager.coffee create mode 100644 plugins/UiFileManager/media/js/lib/Animation.coffee create mode 100644 plugins/UiFileManager/media/js/lib/Class.coffee create mode 100644 plugins/UiFileManager/media/js/lib/Dollar.coffee create mode 100644 plugins/UiFileManager/media/js/lib/ItemList.coffee create mode 100644 plugins/UiFileManager/media/js/lib/Menu.coffee create mode 100644 plugins/UiFileManager/media/js/lib/Promise.coffee create mode 100644 plugins/UiFileManager/media/js/lib/Prototypes.coffee create mode 100644 plugins/UiFileManager/media/js/lib/RateLimitCb.coffee create mode 100644 plugins/UiFileManager/media/js/lib/Text.coffee create mode 100644 plugins/UiFileManager/media/js/lib/Time.coffee create mode 100644 plugins/UiFileManager/media/js/lib/ZeroFrame.coffee create mode 100644 plugins/UiFileManager/media/js/lib/maquette.js create mode 100644 plugins/UiFileManager/media/list.html diff --git a/plugins/UiFileManager/UiFileManagerPlugin.py b/plugins/UiFileManager/UiFileManagerPlugin.py new file mode 100644 index 00000000..0af5bea9 --- /dev/null +++ b/plugins/UiFileManager/UiFileManagerPlugin.py @@ -0,0 +1,84 @@ +import io +import os +import re +import urllib + +from Plugin import PluginManager +from Config import config +from Translate import Translate + +plugin_dir = os.path.dirname(__file__) + +if "_" not in locals(): + _ = Translate(plugin_dir + "/languages/") + + +@PluginManager.registerTo("UiRequest") +class UiFileManagerPlugin(object): + def actionWrapper(self, path, extra_headers=None): + match = re.match("/list/(.*?)(/.*|)$", path) + if not match: + return super().actionWrapper(path, extra_headers) + + if not extra_headers: + extra_headers = {} + + request_address, inner_path = match.groups() + + script_nonce = self.getScriptNonce() + + self.sendHeader(extra_headers=extra_headers, script_nonce=script_nonce) + + site = self.server.site_manager.need(request_address) + + if not site: + return super().actionWrapper(path, extra_headers) + + request_params = urllib.parse.urlencode( + {"address": site.address, "site": request_address, "inner_path": inner_path.strip("/")} + ) + + is_content_loaded = "content.json" in site.content_manager.contents + + return iter([super().renderWrapper( + site, path, "uimedia/plugins/uifilemanager/list.html?%s" % request_params, + "List", extra_headers, show_loadingscreen=not is_content_loaded, script_nonce=script_nonce + )]) + + def actionUiMedia(self, path, *args, **kwargs): + if path.startswith("/uimedia/plugins/uifilemanager/"): + file_path = path.replace("/uimedia/plugins/uifilemanager/", plugin_dir + "/media/") + if config.debug and (file_path.endswith("all.js") or file_path.endswith("all.css")): + # If debugging merge *.css to all.css and *.js to all.js + from Debug import DebugMedia + DebugMedia.merge(file_path) + + if file_path.endswith("js"): + data = _.translateData(open(file_path).read(), mode="js").encode("utf8") + elif file_path.endswith("html"): + if self.get.get("address"): + site = self.server.site_manager.need(self.get.get("address")) + if "content.json" not in site.content_manager.contents: + site.needFile("content.json") + data = _.translateData(open(file_path).read(), mode="html").encode("utf8") + else: + data = open(file_path, "rb").read() + + return self.actionFile(file_path, file_obj=io.BytesIO(data), file_size=len(data)) + else: + return super().actionUiMedia(path) + + def error404(self, path=""): + if not path.endswith("index.html") and not path.endswith("/"): + return super().error404(path) + + path_parts = self.parsePath(path) + site = self.server.site_manager.get(path_parts["request_address"]) + + if not site or not site.content_manager.contents.get("content.json"): + return super().error404(path) + + self.sendHeader(200) + path_redirect = "/list" + re.sub("^/media/", "/", path) + self.log.debug("Index.html not found: %s, redirecting to: %s" % (path, path_redirect)) + return self.formatRedirect(path_redirect) diff --git a/plugins/UiFileManager/__init__.py b/plugins/UiFileManager/__init__.py new file mode 100644 index 00000000..b6c2bd1a --- /dev/null +++ b/plugins/UiFileManager/__init__.py @@ -0,0 +1 @@ +from . import UiFileManagerPlugin diff --git a/plugins/UiFileManager/languages/hu.json b/plugins/UiFileManager/languages/hu.json new file mode 100644 index 00000000..5a915f9f --- /dev/null +++ b/plugins/UiFileManager/languages/hu.json @@ -0,0 +1,20 @@ +{ + "New file name:": "Új fájl neve:", + "Delete": "Törlés", + "Cancel": "Mégse", + "Selected:": "Köjelölt:", + "Delete and remove optional:": "Törlés és opcionális fájl eltávolítása", + " files": " fájl", + " (modified)": " (módostott)", + " (new)": " (új)", + " (optional)": " (opcionális)", + " (ignored from content.json)": " (content.json-ból kihagyott)", + "Total: ": "Összesen: ", + " dir, ": " könyvtár, ", + " file in ": " fájl, ", + "+ New": "+ Új", + "Edit": "Módosít", + "View": "Megnyit", + "Save": "Mentés", + "Save: done!": "Mentés: Kész!" +} \ No newline at end of file diff --git a/plugins/UiFileManager/media/css/Menu.css b/plugins/UiFileManager/media/css/Menu.css new file mode 100644 index 00000000..b61b8be6 --- /dev/null +++ b/plugins/UiFileManager/media/css/Menu.css @@ -0,0 +1,33 @@ +.menu { + background-color: white; padding: 10px 0px; position: absolute; top: 0px; max-height: 0px; overflow: hidden; transform: translate(-100%, -30px); pointer-events: none; + box-shadow: 0px 2px 8px rgba(0,0,0,0.3); border-radius: 2px; opacity: 0; transition: opacity 0.2s ease-out, transform 1s ease-out, max-height 0.2s ease-in-out; z-index: 99; + display: inline-block; z-index: 999; transform-style: preserve-3d; +} +.menu.menu-left { transform: translate(0%, -30px); } +.menu.menu-left.visible { transform: translate(0%, 0px); } +.menu.visible { + opacity: 1; transform: translate(-100%, 0px); pointer-events: all; + transition: opacity 0.1s ease-out, transform 0.3s ease-out, max-height 0.3s cubic-bezier(0.86, 0, 0.07, 1); +} + +.menu-item { + display: block; text-decoration: none; color: black; padding: 6px 24px; transition: all 0.2s; border-bottom: none; font-weight: normal; + max-height: 150px; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 6; -webkit-box-orient: vertical; display: -webkit-box; +} +.menu-item-separator { margin-top: 3px; margin-bottom: 3px; border-top: 1px solid #eee } + +.menu-item.noaction { cursor: default } +.menu-item:hover:not(.noaction) { background-color: #F6F6F6; transition: none; color: inherit; cursor: pointer; color: black } +.menu-item:active:not(.noaction), .menu-item:focus:not(.noaction) { background-color: #AF3BFF !important; color: white !important; transition: none } +.menu-item.selected:before { + content: "L"; display: inline-block; transform: rotateZ(45deg) scaleX(-1); + font-weight: bold; position: absolute; margin-left: -14px; font-size: 12px; margin-top: 2px; +} + +.menu-radio { white-space: normal; line-height: 26px } +.menu-radio a { + background-color: #EEE; width: 18.5%;; text-align: center; margin-top: 2px; margin-bottom: 2px; color: #666; font-weight: bold; + text-decoration: none; font-size: 13px; transition: all 0.3s; text-transform: uppercase; display: inline-block; +} +.menu-radio a:hover, .menu-radio a.selected { transition: none; background-color: #AF3BFF !important; color: white !important } +.menu-radio a.long { font-size: 10px; vertical-align: -1px; } diff --git a/plugins/UiFileManager/media/css/Selectbar.css b/plugins/UiFileManager/media/css/Selectbar.css new file mode 100644 index 00000000..ceaf72e0 --- /dev/null +++ b/plugins/UiFileManager/media/css/Selectbar.css @@ -0,0 +1,17 @@ +.selectbar.visible { margin-top: 0px; visibility: visible } +.selectbar { + position: fixed; top: 0; left: 0; background-color: white; box-shadow: 0px 0px 25px rgba(22, 39, 97, 0.2); margin-top: -75px; + transition: all 0.3s; visibility: hidden; z-index: 9999; color: black; border-left: 5px solid #ede1f582; width: 100%; + padding: 13px; font-size: 13px; font-weight: lighter; backface-visibility: hidden; +} + +.selectbar .num { margin-left: 15px; min-width: 30px; text-align: right; display: inline-block; } +.selectbar .size { margin-left: 10px; color: #9f9ba2; min-width: 75px; display: inline-block; } +.selectbar .actions { display: inline-block; margin-left: 20px; font-size: 13px; text-transform: uppercase; line-height: 20px; } +.selectbar .action { padding: 5px 20px; border: 1px solid #edd4ff; margin-left: 10px; border-radius: 30px; color: #af3bff; text-decoration: none; transition: all 0.3s } +.selectbar .action:hover { border-color: #c788f3; transition: none; color: #9700ff } +.selectbar .delete { color: #AAA; border-color: #DDD; } +.selectbar .delete:hover { color: #333; border-color: #AAA } +.selectbar .action:active { background-color: #af3bff; color: white; border-color: #af3bff; transition: none } +.selectbar .cancel { margin: 20px; font-size: 10px; text-decoration: none; color: #999; text-transform: uppercase; } +.selectbar .cancel:hover { color: #333; transition: none } \ No newline at end of file diff --git a/plugins/UiFileManager/media/css/UiFileManager.css b/plugins/UiFileManager/media/css/UiFileManager.css new file mode 100644 index 00000000..e8bb8550 --- /dev/null +++ b/plugins/UiFileManager/media/css/UiFileManager.css @@ -0,0 +1,148 @@ +body { background-color: #EEEEF5; font-family: "Segoe UI", Helvetica, Arial; height: 95000px; overflow: hidden; } +body.loaded { height: auto; overflow: auto } +h1 { font-weight: lighter; } + +a { color: #333 } +a:hover { text-decoration: none } +input::placeholder { color: rgba(255, 255, 255, 0.3) } + +h2 { font-weight: lighter; } + +.link { background-color: transparent; outline: 5px solid transparent; transition: all 0.3s } +.link:active { background-color: #fbf5ff; outline: 5px solid #fbf5ff; transition: none } + +.manager.editing .files { float: left; width: 280px; } + +.sidebar-button { + display: inline-block; padding: 25px 19px; text-decoration: none; position: absolute; + border-right: 1px solid #EEE; line-height: 10px; color: #7801F5; transition: all 0.3s +} +.sidebar-button:active { background-color: #f5e7ff; transition: none } +/*.sidebar-button:hover { background-color: #fbf5ff; }*/ +.sidebar-button span { transition: 1s all; transform-origin: 2.5px 7px; display: inline-block; } +.manager.sidebar_closed .sidebar-button span { transform: rotateZ(180deg); } +.manager.sidebar_closed .files { margin-left: -300px; } +.manager.sidebar_closed .editor { width: 100%; } + +.button { + padding: 5px 10px; margin-left: 10px; background-color: #752ff2; border-bottom: 2px solid #caadff; background-position: -50px center; + border-radius: 2px; text-decoration: none; transition: all 0.5s ease-out; display: inline-block; + color: #333; font-size: 12px; vertical-align: 2px; text-transform: uppercase; color: white; max-width: 100px; +} +.button:hover { background-color: #9e71ed; transition: none; } +.button:active { position: relative; top: 1px } +.button.loading, .button.disabled { color: rgba(255,255,255,0.7);; pointer-events: none; border-bottom: 2px solid #666; background-color: #999; } +.button.loading { background: #999 url(../img/loading.gif) no-repeat center center; transition: all 0.5s ease-out; color: rgba(0,0,0,0); } +.button.done { background-color: #4dc758; transition: all 0.3s; border-color: #4dc758; pointer-events: none; } +.button.hidden { max-width: 0px; display: inline-block; padding-left: 0px; padding-right: 0px; margin: 0px; } + +/* List */ + +.files { + width: 97%; box-sizing: border-box; color: #555; position: relative; z-index: 1; transition: all 0.6s; + font-size: 14px; box-shadow: 0px 9px 20px -15px #a5cbec; max-width: 400px; border: 1px solid #EEEEF5; +} +.files .tr { white-space: nowrap } +.files .td { display: inline-block; width: 60px } +.files .tbody .td { line-height: 18px; vertical-align: bottom; } +.files .td.name { min-width: 100px } +.files .td.size { width: 60px; text-align: right; padding-left: 5px; } +.files .td.status { text-align: right; } +.files .td.peer { width: 60px } +.files .td.uploaded { width: 130px; text-align: right; } +.files .td.added { width: 90px } +.files .orderby { color: inherit; text-decoration: none; transition: all 0.3s; outline: 5px solid transparent; } +.files .orderby:hover { text-decoration: underline; } +.files .orderby .icon-arrow-down { opacity: 0; transition: all 0.3s ease-in-out; } +.files .orderby.selected .icon-arrow-down { opacity: 0.3; } +.files .orderby:active { background-color: rgba(133, 239, 255, 0.09); outline: 5px solid rgba(133, 239, 255, 0.09); transition: none; } +.files .orderby:hover .icon-arrow-down { opacity: 0.5; } +.files .orderby:not(.desc) .icon-arrow-down { transform: rotateZ(180deg); } +.files .tr.editing .td { background-color: #ede1f582; border-top-color: #ece9ef; } +.files .thead { /*background: linear-gradient(358deg, #e7f1f7, #e9f2f72e);*/ } +.files .thead .td { + border-top: none; color: #8984c2; background-color: #f7f7fc; + font-size: 12px; /*text-transform: uppercase; background-color: transparent; font-weight: bold;*/ +} +.files .thead .td a:last-of-type { font-weight: bold; } +.files .thead .td a { text-decoration: none; } +.files .thead .td a:hover { text-decoration: underline; } +.files .tbody { max-height: calc(100vh - 100x); overflow-y: auto; overflow-x: hidden; } +.files .tr { background-color: white; } +.files .td { padding: 10px 20px; border-top: 1px solid #EEE; font-size: 13px; white-space: nowrap; } +.files .td.full { width: 100%; box-sizing: border-box; white-space: pre-line; } +.files .td.pre { width: 0px; color: transparent; padding-left: 0px; border-left: 2px solid transparent; } +.files .tbody .td { height: 18px; } +.files .tbody .td.full { height: auto; } +.files .td.pre .checkbox-outer { opacity: 0.6; margin-left: -11px; margin-top: -15px; width: 18px; height: 12px; display: inline-block; } +.files .tr.modified .td.pre { border-left-color: #7801F5 } +.files .tr.added .td.pre { border-left-color: #00ec93 } +.files .tr.ignored .td.pre { border-left-color: #999; } +.files .tr.ignored { opacity: 0.5; } +.files .tr.optional { background: linear-gradient(90deg, #fff6dd, 30%, white, 10%, white); } +.files .tr.optional_empty { color: #999; font-style: italic; } +.files .td.error { background-color: #F44336; color: white; } +.files .td.site { width: 70px } +.files .td.site .link { color: inherit; text-decoration: none } +.files .td.status .percent { + transition: all 1s ease-in-out; display: inline-block; width: 80px; background-color: #EEE; font-size: 10px; + height: 15px; line-height: 15px; text-align: center; margin-right: 20px; +} +.files .td.name { padding-left: 10px; width: calc(100% - 167px); max-height: 18px; padding-right: 10px; } +.files .tr.nobuttons .td.name { width: calc(100% - 127px); } +.files .tr.nobuttons .td.buttons { width: 0px; } +.files .td.name .title { color: inherit; text-decoration: none } +.files .td.name .link { display: inline-block; overflow: hidden; text-overflow: ellipsis; vertical-align: -4px; max-width: 100%; } +.files .pinned .td.name .link { max-width: calc(100% - 40px); } +.files .thead .td.uploaded { text-align: left } +.files .thead .td.uploaded .title { padding-left: 7px; } +.files .peer .icon-profile { background: currentColor; color: #47d094; font-size: 10px; top: 1px; margin-right: 13px } +.files .peer .icon-profile:before { background: currentColor } +.files .peer .num { color: #969696; } +.files .uploaded .uploaded-text { display: inline-block; text-align: right; } +.files .uploaded .dots-container { display: inline-block; width: 0px; padding-right: 65px;; } +.files .td.buttons { width: 40px; padding-left: 0px; padding-right: 0px; } +.files .td.buttons .edit { + background-color: #2196f336; border-radius: 15px; padding: 1px 9px; font-size: 80%; text-decoration: none; color: #1976D2; +} +.files .checkbox-outer { padding: 15px; padding-left: 20px; padding-right: 0px; } +.files .checkbox { + display: inline-block; width: 12px; height: 12px; border: 2px solid #00000014; + border-radius: 3px; vertical-align: -3px; margin-right: 10px; +} +.files .selected .checkbox { border-color: #dedede } +.files .selected .checkbox:after { + background-color: #dedede; content: ""; text-decoration: none; display: block; width: 10px; height: 10px; margin-left: 1px; margin-top: 1px; +} +.files .tbody .td.size { font-size: 13px } +.files .tbody .td.added, #PageFiles .files .td.access { font-size: 12px; color: #999 } +.files .tr.type-dir .name { font-weight: bold; } +.files .tr.type-parent .name .link { display: inline-block; width: 100%; padding: 5px; margin-top: -5px; } +.files .foot .td { color: #a4a4a4; background-color: #f7f7fc; } +.files .foot .create { float: right; text-decoration: none; position: relative; } +.files .foot .create .link { color: #8c42ed; text-decoration: none; } +.files .foot .create .link:active { background-color: #8c42ed3b; outline: 5px solid #8c42ed3b; } +.files .foot .create .menu { top: 40px; } + + +/* Editor */ + +.editor { background-color: #F7F7FC; float: left; width: calc(100% - 280px); box-sizing: border-box; transition: all 0.6s; } +.editor .CodeMirror { height: calc(100vh - 73px); visibility: hidden; } +.editor textarea { width: 100%; height: 800px; white-space: pre; } +.editor .title { margin-left: 20px; } +.editor .editor-head { + padding: 15px 20px; padding-left: 45px; font-size: 18px; font-weight: lighter; border: 1px solid #EEEEF5; + white-space: nowrap; overflow: hidden; +} +.editor.loaded .CodeMirror { visibility: inherit; } +.editor.error .CodeMirror { display: none; } +.editor .button.save { min-width: 30px; text-align: center; transition: all 0.3s; } +.editor .button.save.done { min-width: 80px; } +.editor .error-message { text-align: center; padding: 50px; } + +.editor .CodeMirror-foldmarker { + line-height: .3; cursor: pointer; background-color: #ffeb3b61; text-shadow: none; font-family: inherit; + color: #050505; border: 1px solid #ffdf7f; padding: 0px 5px; +} +.editor .CodeMirror-activeline-background { background-color: #F6F6F6 !important; } \ No newline at end of file diff --git a/plugins/UiFileManager/media/img/loading.gif b/plugins/UiFileManager/media/img/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..27d0aa8108b0800f9cddcf613f787347d9981e05 GIT binary patch literal 723 zcmZ?wbhEHb6ky0iXa9k4z>53@HBR_Hzvhc6JPKHPSO+W(0~W{*!Vp zN=+!e)X>LZSp~!n_rkGVK%h z9k_L9<(o^(d!N7A`+9eTzQ!EZMr*-N2_|eB&45;SC+a-zP~lXP;z?eTv`FKm^!Y8l zuZ^S*OlLmOv^VZNyYQx zE1Q1d^QD!~t!MErXFkzlm$bqCmuUZ)iN%&IQkAQ(b??%e8>EQMBqK<8T-y}!%q4L0 z4v$MoL7}cEx5PfOihDclHe=f1_`ny+jJ+qGonTF#=e6?cS1GK1Glv+XQW)E^VpGzx z%$u!=(=#3~+Lk*jmQUf$-=^(}f)AMWru(Y&&oE(%*JUs>JH24vgCGuUPSS^%^#tgi z6`S6zDw0tR+QR$5bp7w`G6mDQzjYm%RoE)?D^8cegv~i}{SvGJM6wyypd + @need_update = true + @on_loaded = new Promise() + @is_loading = false + @content = "" + @node_cm = null + @cm = null + @error = null + @is_loaded = false + @is_modified = false + @is_saving = false + @mode = "Loading" + + update: -> + is_required = Page.url_params.get("edit_mode") != "new" + + Page.cmd "fileGet", {inner_path: @inner_path, required: is_required}, (res) => + if res?.error + @error = res.error + @content = res.error + @log "Error loading: #{@error}" + else + if res + @content = res + else + @content = "" + @mode = "Create" + if not @content + @cm.getDoc().clearHistory() + @cm.setValue(@content) + if not @error + @is_loaded = true + Page.projector.scheduleRender() + + isModified: => + return @content != @cm.getValue() + + storeCmNode: (node) => + @node_cm = node + + getMode: (inner_path) -> + ext = inner_path.split(".").pop() + types = { + "py": "python", + "json": "application/json", + "js": "javascript", + "coffee": "coffeescript", + "html": "htmlmixed", + "htm": "htmlmixed", + "php": "htmlmixed", + "rs": "rust", + "css": "css", + "md": "markdown", + "xml": "xml", + "svg": "xml" + } + return types[ext] + + foldJson: (from, to) => + @log "foldJson", from, to + # Get open / close token + startToken = '{' + endToken = '}' + prevLine = @cm.getLine(from.line) + if prevLine.lastIndexOf('[') > prevLine.lastIndexOf('{') + startToken = '[' + endToken = ']' + + # Get json content + internal = @cm.getRange(from, to) + toParse = startToken + internal + endToken + + #Get key count + try + parsed = JSON.parse(toParse) + count = Object.keys(parsed).length + catch e + null + + return if count then "\u21A4#{count}\u21A6" else "\u2194" + + createCodeMirror: -> + mode = @getMode(@inner_path) + @log "Creating CodeMirror", @inner_path, mode + options = { + value: "Loading...", + mode: mode, + lineNumbers: true, + styleActiveLine: true, + matchBrackets: true, + keyMap: "sublime", + theme: "mdn-like", + extraKeys: {"Ctrl-Space": "autocomplete"}, + foldGutter: true, + gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"] + + } + if mode == "application/json" + options.gutters.unshift("CodeMirror-lint-markers") + options.lint = true + options.foldOptions = { widget: @foldJson } + + @cm = CodeMirror(@node_cm, options) + @cm.on "changes", (changes) => + if @is_loaded and not @is_modified + @is_modified = true + Page.projector.scheduleRender() + + + loadEditor: -> + if not @is_loading + document.getElementsByTagName("head")[0].insertAdjacentHTML( + "beforeend", + """""" + ) + script = document.createElement('script') + script.src = "codemirror/all.js" + script.onload = => + @createCodeMirror() + @on_loaded.resolve() + document.head.appendChild(script) + return @on_loaded + + handleSidebarButtonClick: => + Page.is_sidebar_closed = not Page.is_sidebar_closed + return false + + handleSaveClick: => + @is_saving = true + Page.cmd "fileWrite", [@inner_path, Text.fileEncode(@cm.getValue())], (res) => + @is_saving = false + if res.error + Page.cmd "wrapperNotification", ["error", "Error saving #{res.error}"] + else + @is_save_done = true + setTimeout (() => + @is_save_done = false + Page.projector.scheduleRender() + ), 2000 + @content = @cm.getValue() + @is_modified = false + if @mode == "Create" + @mode = "Edit" + Page.file_list.need_update = true + Page.projector.scheduleRender() + return false + + render: -> + if @need_update + @loadEditor().then => + @update() + @need_update = false + h("div.editor", {afterCreate: @storeCmNode, classes: {error: @error, loaded: @is_loaded}}, [ + h("a.sidebar-button", {href: "#Sidebar", onclick: @handleSidebarButtonClick}, h("span", "\u2039")), + h("div.editor-head", [ + if @mode in ["Edit", "Create"] + h("a.save.button", + {href: "#Save", classes: {loading: @is_saving, done: @is_save_done, disabled: not @is_modified}, onclick: @handleSaveClick}, + if @is_save_done then "Save: done!" else "Save" + ) + h("span.title", @mode, ": ", @inner_path) + ]), + if @error + h("div.error-message", + h("h2", "Unable to load the file: #{@error}") + h("a", {href: Page.file_list.getHref(@inner_path)}, "View in browser") + ) + ]) + +window.FileEditor = FileEditor \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/FileItemList.coffee b/plugins/UiFileManager/media/js/FileItemList.coffee new file mode 100644 index 00000000..df22b81a --- /dev/null +++ b/plugins/UiFileManager/media/js/FileItemList.coffee @@ -0,0 +1,194 @@ +class FileItemList extends Class + constructor: (@inner_path) -> + @items = [] + @updating = false + @files_modified = {} + @dirs_modified = {} + @files_added = {} + @dirs_added = {} + @files_optional = {} + @items_by_name = {} + + # Update item list + update: (cb) -> + @updating = true + @logStart("Updating dirlist") + Page.cmd "dirList", {inner_path: @inner_path, stats: true}, (res) => + if res.error + @error = res.error + else + @error = null + pattern_ignore = RegExp("^" + Page.site_info.content?.ignore) + + @items.splice(0, @items.length) # Remove all items + + @items_by_name = {} + for row in res + row.type = @getFileType(row) + row.inner_path = @inner_path + row.name + if Page.site_info.content?.ignore and row.inner_path.match(pattern_ignore) + row.ignored = true + @items.push(row) + @items_by_name[row.name] = row + + @sort() + + if Page.site_info?.settings?.own + @updateAddedFiles() + + @updateOptionalFiles => + @updating = false + cb?() + @logEnd("Updating dirlist", @inner_path) + Page.projector.scheduleRender() + + @updateModifiedFiles => + Page.projector.scheduleRender() + + + updateModifiedFiles: (cb) => + # Add modified attribute to changed files + Page.cmd "siteListModifiedFiles", [], (res) => + @files_modified = {} + @dirs_modified = {} + for inner_path in res.modified_files + @files_modified[inner_path] = true + dir_inner_path = "" + dir_parts = inner_path.split("/") + for dir_part in dir_parts[..-2] + if dir_inner_path + dir_inner_path += "/#{dir_part}" + else + dir_inner_path = dir_part + @dirs_modified[dir_inner_path] = true + + cb?() + + # Update newly added items list since last sign + updateAddedFiles: => + Page.cmd "fileGet", "content.json", (res) => + if not res + return false + + content = JSON.parse(res) + + # Check new files + if not content.files? + return false + + @files_added = {} + + for file in @items + if file.name == "content.json" or file.is_dir + continue + if not content.files[@inner_path + file.name] + @files_added[@inner_path + file.name] = true + + # Check new dirs + @dirs_added = {} + + dirs_content = {} + for file_name of Object.assign({}, content.files, content.files_optional) + if not file_name.startsWith(@inner_path) + continue + + pattern = new RegExp("#{@inner_path}(.*?)/") + match = file_name.match(pattern) + + if not match + continue + + dirs_content[match[1]] = true + + for file in @items + if not file.is_dir + continue + if not dirs_content[file.name] + @dirs_added[@inner_path + file.name] = true + + # Update optional files list + updateOptionalFiles: (cb) => + Page.cmd "optionalFileList", {filter: ""}, (res) => + @files_optional = {} + for optional_file in res + @files_optional[optional_file.inner_path] = optional_file + + @addOptionalFilesToItems() + + cb?() + + # Add optional files to item list + addOptionalFilesToItems: => + is_added = false + for inner_path, optional_file of @files_optional + if optional_file.inner_path.startsWith(@inner_path) + if @getDirectory(optional_file.inner_path) == @inner_path + # Add optional file to list + file_name = @getFileName(optional_file.inner_path) + if not @items_by_name[file_name] + row = { + "name": file_name, "type": "file", "optional_empty": true, + "size": optional_file.size, "is_dir": false, "inner_path": optional_file.inner_path + } + @items.push(row) + @items_by_name[file_name] = row + is_added = true + else + # Add optional dir to list + dir_name = optional_file.inner_path.replace(@inner_path, "").match(/(.*?)\//, "")?[1] + if dir_name and not @items_by_name[dir_name] + row = { + "name": dir_name, "type": "dir", "optional_empty": true, + "size": 0, "is_dir": true, "inner_path": optional_file.inner_path + } + @items.push(row) + @items_by_name[dir_name] = row + is_added = true + + if is_added + @sort() + + getFileType: (file) => + if file.is_dir + return "dir" + else + return "unknown" + + getDirectory: (inner_path) -> + if inner_path.indexOf("/") != -1 + return inner_path.replace(/^(.*\/)(.*?)$/, "$1") + else + return "" + + getFileName: (inner_path) -> + return inner_path.replace(/^(.*\/)(.*?)$/, "$2") + + + isModified: (inner_path) => + return @files_modified[inner_path] or @dirs_modified[inner_path] + + isAdded: (inner_path) => + return @files_added[inner_path] or @dirs_added[inner_path] + + hasPermissionDelete: (file) => + if file.type in ["dir", "parent"] + return false + + if file.inner_path == "content.json" + return false + + optional_info = @getOptionalInfo(file.inner_path) + if optional_info and optional_info.downloaded_percent > 0 + return true + else + return Page.site_info?.settings?.own + + getOptionalInfo: (inner_path) => + return @files_optional[inner_path] + + sort: => + @items.sort (a, b) -> + return (b.is_dir - a.is_dir) || a.name.localeCompare(b.name) + + +window.FileItemList = FileItemList \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/FileList.coffee b/plugins/UiFileManager/media/js/FileList.coffee new file mode 100644 index 00000000..2e38c2ae --- /dev/null +++ b/plugins/UiFileManager/media/js/FileList.coffee @@ -0,0 +1,270 @@ +BINARY_EXTENSIONS = ["png", "gif", "jpg", "pdf", "doc", "msgpack", "zip", "rar", "gz", "tar", "exe"] + +class FileList extends Class + constructor: (@site, @inner_path, @is_owner=false) -> + @need_update = true + @error = null + @url_root = "/list/" + @site + "/" + if @inner_path + @inner_path += "/" + @url_root += @inner_path + @log("inited", @url_root) + @item_list = new FileItemList(@inner_path) + @item_list.items = @item_list.items + @menu_create = new Menu() + + @select_action = null + @selected = {} + @selected_items_num = 0 + @selected_items_size = 0 + @selected_optional_empty_num = 0 + + isSelectedAll: -> + false + + update: => + @item_list.update => + document.body.classList.add("loaded") + + getHref: (inner_path) => + return "/" + @site + "/" + inner_path + + getListHref: (inner_path) => + return "/list/" + @site + "/" + inner_path + + getEditHref: (inner_path, mode=null) => + href = @url_root + "?file=" + inner_path + if mode + href += "&edit_mode=#{mode}" + return href + + checkSelectedItems: => + @selected_items_num = 0 + @selected_items_size = 0 + @selected_optional_empty_num = 0 + for item in @item_list.items + if @selected[item.inner_path] + @selected_items_num += 1 + @selected_items_size += item.size + optional_info = @item_list.getOptionalInfo(item.inner_path) + if optional_info and not optional_info.downloaded_percent > 0 + @selected_optional_empty_num += 1 + + handleMenuCreateClick: => + @menu_create.items = [] + @menu_create.items.push ["File", @handleNewFileClick] + @menu_create.items.push ["Directory", @handleNewDirectoryClick] + @menu_create.toggle() + return false + + handleNewFileClick: => + Page.cmd "wrapperPrompt", "New file name:", (file_name) => + window.top.location.href = @getEditHref(@inner_path + file_name, "new") + return false + + handleNewDirectoryClick: => + Page.cmd "wrapperPrompt", "New directory name:", (res) => + alert("directory name #{res}") + return false + + handleSelectClick: (e) => + return false + + handleSelectEnd: (e) => + document.body.removeEventListener('mouseup', @handleSelectEnd) + @select_action = null + + handleSelectMousedown: (e) => + inner_path = e.currentTarget.attributes.inner_path.value + if @selected[inner_path] + delete @selected[inner_path] + @select_action = "deselect" + else + @selected[inner_path] = true + @select_action = "select" + @checkSelectedItems() + document.body.addEventListener('mouseup', @handleSelectEnd) + e.stopPropagation() + Page.projector.scheduleRender() + return false + + handleRowMouseenter: (e) => + if e.buttons and @select_action + inner_path = e.target.attributes.inner_path.value + if @select_action == "select" + @selected[inner_path] = true + else + delete @selected[inner_path] + @checkSelectedItems() + Page.projector.scheduleRender() + return false + + handleSelectbarCancel: => + @selected = {} + @checkSelectedItems() + Page.projector.scheduleRender() + return false + + handleSelectbarDelete: (e, remove_optional=false) => + for inner_path of @selected + optional_info = @item_list.getOptionalInfo(inner_path) + delete @selected[inner_path] + if optional_info and not remove_optional + Page.cmd "optionalFileDelete", inner_path + else + Page.cmd "fileDelete", inner_path + @need_update = true + Page.projector.scheduleRender() + @checkSelectedItems() + return false + + handleSelectbarRemoveOptional: (e) => + return @handleSelectbarDelete(e, true) + + renderSelectbar: => + h("div.selectbar", {classes: {visible: @selected_items_num > 0}}, [ + "Selected:", + h("span.info", [ + h("span.num", "#{@selected_items_num} files"), + h("span.size", "(#{Text.formatSize(@selected_items_size)})"), + ]) + h("div.actions", [ + if @selected_optional_empty_num > 0 + h("a.action.delete.remove_optional", {href: "#", onclick: @handleSelectbarRemoveOptional}, "Delete and remove optional") + else + h("a.action.delete", {href: "#", onclick: @handleSelectbarDelete}, "Delete") + ]) + h("a.cancel.link", {href: "#", onclick: @handleSelectbarCancel}, "Cancel") + ]) + + renderHead: => + parent_links = [] + inner_path_parent = "" + for parent_dir in @inner_path.split("/") + if not parent_dir + continue + if inner_path_parent + inner_path_parent += "/" + inner_path_parent += "#{parent_dir}" + parent_links.push( + [" / ", h("a", {href: @getListHref(inner_path_parent)}, parent_dir)] + ) + return h("div.tr.thead", h("div.td.full", + h("a", {href: @getListHref("")}, "root"), + parent_links + )) + + renderItemCheckbox: (item) => + if not @item_list.hasPermissionDelete(item) + return [" "] + + return h("a.checkbox-outer", { + href: "#Select", + onmousedown: @handleSelectMousedown, + onclick: @handleSelectClick, + inner_path: item.inner_path + }, h("span.checkbox")) + + renderItem: (item) => + if item.type == "parent" + href = @url_root.replace(/^(.*)\/.{2,255}?$/, "$1/") + else if item.type == "dir" + href = @url_root + item.name + else + href = @url_root.replace(/^\/list\//, "/") + item.name + + inner_path = @inner_path + item.name + href_edit = @getEditHref(inner_path) + is_dir = item.type in ["dir", "parent"] + ext = item.name.split(".").pop() + + is_editing = inner_path == Page.file_editor?.inner_path + is_editable = not is_dir and item.size < 1024 * 1024 and ext not in BINARY_EXTENSIONS + is_modified = @item_list.isModified(inner_path) + is_added = @item_list.isAdded(inner_path) + optional_info = @item_list.getOptionalInfo(inner_path) + + style = "" + title = "" + + if optional_info + downloaded_percent = optional_info.downloaded_percent + if not downloaded_percent + downloaded_percent = 0 + style += "background: linear-gradient(90deg, #fff6dd, #{downloaded_percent}%, white, #{downloaded_percent}%, white);" + is_added = false + + if item.ignored + is_added = false + + if is_modified then title += " (modified)" + if is_added then title += " (new)" + if optional_info or item.optional_empty then title += " (optional)" + if item.ignored then title += " (ignored from content.json)" + + classes = { + "type-#{item.type}": true, editing: is_editing, nobuttons: not is_editable, selected: @selected[inner_path], + modified: is_modified, added: is_added, ignored: item.ignored, optional: optional_info, optional_empty: item.optional_empty + } + + h("div.tr", {key: item.name, classes: classes, style: style, onmouseenter: @handleRowMouseenter, inner_path: inner_path}, [ + h("div.td.pre", {title: title}, + @renderItemCheckbox(item) + ), + h("div.td.name", h("a.link", {href: href}, item.name)) + h("div.td.buttons", if is_editable then h("a.edit", {href: href_edit}, if Page.site_info.settings.own then "Edit" else "View")) + h("div.td.size", if is_dir then "[DIR]" else Text.formatSize(item.size)) + ]) + + + renderItems: => + return [ + if @item_list.error and not @item_list.items.length and not @item_list.updating then [ + h("div.tr", {key: "error"}, h("div.td.full.error", @item_list.error)) + ], + if @inner_path then @renderItem({"name": "..", type: "parent", size: 0}) + @item_list.items.map @renderItem + ] + + renderFoot: => + files = (item for item in @item_list.items when item.type not in ["parent", "dir"]) + dirs = (item for item in @item_list.items when item.type == "dir") + if files.length + total_size = (item.size for file in files).reduce (a, b) -> a + b + else + total_size = 0 + + foot_text = "Total: " + foot_text += "#{dirs.length} dir, #{files.length} file in #{Text.formatSize(total_size)}" + + return [ + if dirs.length or files.length or Page.site_info?.settings?.own + h("div.tr.foot-info.foot", h("div.td.full", [ + if @item_list.updating + "Updating file list..." + else + if dirs.length or files.length then foot_text + if Page.site_info?.settings?.own + h("div.create", [ + h("a.link", {href: "#Create+new+file", onclick: @handleNewFileClick}, "+ New") + @menu_create.render() + ]) + ])) + ] + + render: => + if @need_update + @update() + @need_update = false + + if not @item_list.items + return [] + + return h("div.files", [ + @renderSelectbar(), + @renderHead(), + h("div.tbody", @renderItems()), + @renderFoot() + ]) + +window.FileList = FileList \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/UiFileManager.coffee b/plugins/UiFileManager/media/js/UiFileManager.coffee new file mode 100644 index 00000000..2126f3b1 --- /dev/null +++ b/plugins/UiFileManager/media/js/UiFileManager.coffee @@ -0,0 +1,79 @@ +window.h = maquette.h + +class UiFileManager extends ZeroFrame + init: -> + @url_params = new URLSearchParams(window.location.search) + @list_site = @url_params.get("site") + @list_address = @url_params.get("address") + @list_inner_path = @url_params.get("inner_path") + @editor_inner_path = @url_params.get("file") + @file_list = new FileList(@list_site, @list_inner_path) + + @site_info = null + @server_info = null + + @is_sidebar_closed = false + + if @editor_inner_path + @file_editor = new FileEditor(@editor_inner_path) + + window.onbeforeunload = => + if @file_editor?.isModified() + return true + else + return null + + window.onresize = => + @checkBodyWidth() + + @checkBodyWidth() + + @cmd("wrapperSetViewport", "width=device-width, initial-scale=0.8") + + @cmd "serverInfo", {}, (server_info) => + @server_info = server_info + @cmd "siteInfo", {}, (site_info) => + @cmd("wrapperSetTitle", "List: /#{@list_inner_path} - #{site_info.content.title} - ZeroNet") + @site_info = site_info + if @file_editor then @file_editor.on_loaded.then => + @file_editor.cm.setOption("readOnly", not site_info.settings.own) + @file_editor.mode = if site_info.settings.own then "Edit" else "View" + @projector.scheduleRender() + + checkBodyWidth: => + if not @file_editor + return false + + if document.body.offsetWidth < 960 and not @is_sidebar_closed + @is_sidebar_closed = true + @projector?.scheduleRender() + else if document.body.offsetWidth > 960 and @is_sidebar_closed + @is_sidebar_closed = false + @projector?.scheduleRender() + + onRequest: (cmd, message) => + if cmd == "setSiteInfo" + @site_info = message + RateLimitCb 1000, (cb_done) => + @file_list.update(cb_done) + @projector.scheduleRender() + else if cmd == "setServerInfo" + @server_info = message + @projector.scheduleRender() + else + @log "Unknown incoming message:", cmd + + createProjector: => + @projector = maquette.createProjector() + @projector.replace($("#content"), @render) + + render: => + return h("div.content#content", [ + h("div.manager", {classes: {editing: @file_editor, sidebar_closed: @is_sidebar_closed}}, [ + @file_list.render(), + if @file_editor then @file_editor.render() + ]) + ]) + +window.Page = new UiFileManager() +window.Page.createProjector() diff --git a/plugins/UiFileManager/media/js/lib/Animation.coffee b/plugins/UiFileManager/media/js/lib/Animation.coffee new file mode 100644 index 00000000..271b88c1 --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/Animation.coffee @@ -0,0 +1,138 @@ +class Animation + slideDown: (elem, props) -> + if elem.offsetTop > 2000 + return + + h = elem.offsetHeight + cstyle = window.getComputedStyle(elem) + margin_top = cstyle.marginTop + margin_bottom = cstyle.marginBottom + padding_top = cstyle.paddingTop + padding_bottom = cstyle.paddingBottom + transition = cstyle.transition + + elem.style.boxSizing = "border-box" + elem.style.overflow = "hidden" + elem.style.transform = "scale(0.6)" + elem.style.opacity = "0" + elem.style.height = "0px" + elem.style.marginTop = "0px" + elem.style.marginBottom = "0px" + elem.style.paddingTop = "0px" + elem.style.paddingBottom = "0px" + elem.style.transition = "none" + + setTimeout (-> + elem.className += " animate-inout" + elem.style.height = h+"px" + elem.style.transform = "scale(1)" + elem.style.opacity = "1" + elem.style.marginTop = margin_top + elem.style.marginBottom = margin_bottom + elem.style.paddingTop = padding_top + elem.style.paddingBottom = padding_bottom + ), 1 + + elem.addEventListener "transitionend", -> + elem.classList.remove("animate-inout") + elem.style.transition = elem.style.transform = elem.style.opacity = elem.style.height = null + elem.style.boxSizing = elem.style.marginTop = elem.style.marginBottom = null + elem.style.paddingTop = elem.style.paddingBottom = elem.style.overflow = null + elem.removeEventListener "transitionend", arguments.callee, false + + + slideUp: (elem, remove_func, props) -> + if elem.offsetTop > 1000 + return remove_func() + + elem.className += " animate-back" + elem.style.boxSizing = "border-box" + elem.style.height = elem.offsetHeight+"px" + elem.style.overflow = "hidden" + elem.style.transform = "scale(1)" + elem.style.opacity = "1" + elem.style.pointerEvents = "none" + setTimeout (-> + elem.style.height = "0px" + elem.style.marginTop = "0px" + elem.style.marginBottom = "0px" + elem.style.paddingTop = "0px" + elem.style.paddingBottom = "0px" + elem.style.transform = "scale(0.8)" + elem.style.borderTopWidth = "0px" + elem.style.borderBottomWidth = "0px" + elem.style.opacity = "0" + ), 1 + elem.addEventListener "transitionend", (e) -> + if e.propertyName == "opacity" or e.elapsedTime >= 0.6 + elem.removeEventListener "transitionend", arguments.callee, false + remove_func() + + + slideUpInout: (elem, remove_func, props) -> + elem.className += " animate-inout" + elem.style.boxSizing = "border-box" + elem.style.height = elem.offsetHeight+"px" + elem.style.overflow = "hidden" + elem.style.transform = "scale(1)" + elem.style.opacity = "1" + elem.style.pointerEvents = "none" + setTimeout (-> + elem.style.height = "0px" + elem.style.marginTop = "0px" + elem.style.marginBottom = "0px" + elem.style.paddingTop = "0px" + elem.style.paddingBottom = "0px" + elem.style.transform = "scale(0.8)" + elem.style.borderTopWidth = "0px" + elem.style.borderBottomWidth = "0px" + elem.style.opacity = "0" + ), 1 + elem.addEventListener "transitionend", (e) -> + if e.propertyName == "opacity" or e.elapsedTime >= 0.6 + elem.removeEventListener "transitionend", arguments.callee, false + remove_func() + + + showRight: (elem, props) -> + elem.className += " animate" + elem.style.opacity = 0 + elem.style.transform = "TranslateX(-20px) Scale(1.01)" + setTimeout (-> + elem.style.opacity = 1 + elem.style.transform = "TranslateX(0px) Scale(1)" + ), 1 + elem.addEventListener "transitionend", -> + elem.classList.remove("animate") + elem.style.transform = elem.style.opacity = null + + + show: (elem, props) -> + delay = arguments[arguments.length-2]?.delay*1000 or 1 + elem.style.opacity = 0 + setTimeout (-> + elem.className += " animate" + ), 1 + setTimeout (-> + elem.style.opacity = 1 + ), delay + elem.addEventListener "transitionend", -> + elem.classList.remove("animate") + elem.style.opacity = null + elem.removeEventListener "transitionend", arguments.callee, false + + hide: (elem, remove_func, props) -> + delay = arguments[arguments.length-2]?.delay*1000 or 1 + elem.className += " animate" + setTimeout (-> + elem.style.opacity = 0 + ), delay + elem.addEventListener "transitionend", (e) -> + if e.propertyName == "opacity" + remove_func() + + addVisibleClass: (elem, props) -> + setTimeout -> + elem.classList.add("visible") + +window.Animation = new Animation() \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/lib/Class.coffee b/plugins/UiFileManager/media/js/lib/Class.coffee new file mode 100644 index 00000000..d62ab25c --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/Class.coffee @@ -0,0 +1,23 @@ +class Class + trace: true + + log: (args...) -> + return unless @trace + return if typeof console is 'undefined' + args.unshift("[#{@.constructor.name}]") + console.log(args...) + @ + + logStart: (name, args...) -> + return unless @trace + @logtimers or= {} + @logtimers[name] = +(new Date) + @log "#{name}", args..., "(started)" if args.length > 0 + @ + + logEnd: (name, args...) -> + ms = +(new Date)-@logtimers[name] + @log "#{name}", args..., "(Done in #{ms}ms)" + @ + +window.Class = Class \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/lib/Dollar.coffee b/plugins/UiFileManager/media/js/lib/Dollar.coffee new file mode 100644 index 00000000..7f19f551 --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/Dollar.coffee @@ -0,0 +1,3 @@ +window.$ = (selector) -> + if selector.startsWith("#") + return document.getElementById(selector.replace("#", "")) diff --git a/plugins/UiFileManager/media/js/lib/ItemList.coffee b/plugins/UiFileManager/media/js/lib/ItemList.coffee new file mode 100644 index 00000000..902e76cd --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/ItemList.coffee @@ -0,0 +1,26 @@ +class ItemList + constructor: (@item_class, @key) -> + @items = [] + @items_bykey = {} + + sync: (rows, item_class, key) -> + @items.splice(0, @items.length) # Empty items + for row in rows + current_obj = @items_bykey[row[@key]] + if current_obj + current_obj.row = row + @items.push current_obj + else + item = new @item_class(row, @) + @items_bykey[row[@key]] = item + @items.push item + + deleteItem: (item) -> + index = @items.indexOf(item) + if index > -1 + @items.splice(index, 1) + else + console.log "Can't delete item", item + delete @items_bykey[item.row[@key]] + +window.ItemList = ItemList \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/lib/Menu.coffee b/plugins/UiFileManager/media/js/lib/Menu.coffee new file mode 100644 index 00000000..ed5fbd54 --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/Menu.coffee @@ -0,0 +1,110 @@ +class Menu + constructor: -> + @visible = false + @items = [] + @node = null + @height = 0 + @direction = "bottom" + + show: => + window.visible_menu?.hide() + @visible = true + window.visible_menu = @ + @direction = @getDirection() + + hide: => + @visible = false + + toggle: => + if @visible + @hide() + else + @show() + Page.projector.scheduleRender() + + + addItem: (title, cb, selected=false) -> + @items.push([title, cb, selected]) + + + storeNode: (node) => + @node = node + # Animate visible + if @visible + node.className = node.className.replace("visible", "") + setTimeout (=> + node.className += " visible" + node.attributes.style.value = @getStyle() + ), 20 + node.style.maxHeight = "none" + @height = node.offsetHeight + node.style.maxHeight = "0px" + @direction = @getDirection() + + getDirection: => + if @node and @node.parentNode.getBoundingClientRect().top + @height + 60 > document.body.clientHeight and @node.parentNode.getBoundingClientRect().top - @height > 0 + return "top" + else + return "bottom" + + handleClick: (e) => + keep_menu = false + for item in @items + [title, cb, selected] = item + if title == e.currentTarget.textContent or e.currentTarget["data-title"] == title + keep_menu = cb?(item) + break + if keep_menu != true and cb != null + @hide() + return false + + renderItem: (item) => + [title, cb, selected] = item + if typeof(selected) == "function" + selected = selected() + + if title == "---" + return h("div.menu-item-separator", {key: Time.timestamp()}) + else + if cb == null + href = undefined + onclick = @handleClick + else if typeof(cb) == "string" # Url + href = cb + onclick = true + else # Callback + href = "#"+title + onclick = @handleClick + classes = { + "selected": selected, + "noaction": (cb == null) + } + return h("a.menu-item", {href: href, onclick: onclick, "data-title": title, key: title, classes: classes}, title) + + getStyle: => + if @visible + max_height = @height + else + max_height = 0 + style = "max-height: #{max_height}px" + if @direction == "top" + style += ";margin-top: #{0 - @height - 50}px" + else + style += ";margin-top: 0px" + return style + + render: (class_name="") => + if @visible or @node + h("div.menu#{class_name}", {classes: {"visible": @visible}, style: @getStyle(), afterCreate: @storeNode}, @items.map(@renderItem)) + +window.Menu = Menu + +# Hide menu on outside click +document.body.addEventListener "mouseup", (e) -> + if not window.visible_menu or not window.visible_menu.node + return false + menu_node = window.visible_menu.node + menu_parents = [menu_node, menu_node.parentNode] + if e.target.parentNode not in menu_parents and e.target.parentNode.parentNode not in menu_parents + window.visible_menu.hide() + Page.projector.scheduleRender() diff --git a/plugins/UiFileManager/media/js/lib/Promise.coffee b/plugins/UiFileManager/media/js/lib/Promise.coffee new file mode 100644 index 00000000..136e3ec7 --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/Promise.coffee @@ -0,0 +1,74 @@ +# From: http://dev.bizo.com/2011/12/promises-in-javascriptcoffeescript.html + +class Promise + @when: (tasks...) -> + num_uncompleted = tasks.length + args = new Array(num_uncompleted) + promise = new Promise() + + for task, task_id in tasks + ((task_id) -> + task.then(() -> + args[task_id] = Array.prototype.slice.call(arguments) + num_uncompleted-- + promise.complete.apply(promise, args) if num_uncompleted == 0 + ) + )(task_id) + + return promise + + constructor: -> + @resolved = false + @end_promise = null + @result = null + @callbacks = [] + + resolve: -> + if @resolved + return false + @resolved = true + @data = arguments + if not arguments.length + @data = [true] + @result = @data[0] + for callback in @callbacks + back = callback.apply callback, @data + if @end_promise + @end_promise.resolve(back) + + fail: -> + @resolve(false) + + then: (callback) -> + if @resolved == true + callback.apply callback, @data + return + + @callbacks.push callback + + @end_promise = new Promise() + +window.Promise = Promise + +### +s = Date.now() +log = (text) -> + console.log Date.now()-s, Array.prototype.slice.call(arguments).join(", ") + +log "Started" + +cmd = (query) -> + p = new Promise() + setTimeout ( -> + p.resolve query+" Result" + ), 100 + return p + +back = cmd("SELECT * FROM message").then (res) -> + log res + return "Return from query" +.then (res) -> + log "Back then", res + +log "Query started", back +### \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/lib/Prototypes.coffee b/plugins/UiFileManager/media/js/lib/Prototypes.coffee new file mode 100644 index 00000000..8026ee5d --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/Prototypes.coffee @@ -0,0 +1,9 @@ +String::startsWith = (s) -> @[...s.length] is s +String::endsWith = (s) -> s is '' or @[-s.length..] is s +String::repeat = (count) -> new Array( count + 1 ).join(@) + +window.isEmpty = (obj) -> + for key of obj + return false + return true + diff --git a/plugins/UiFileManager/media/js/lib/RateLimitCb.coffee b/plugins/UiFileManager/media/js/lib/RateLimitCb.coffee new file mode 100644 index 00000000..a7316f53 --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/RateLimitCb.coffee @@ -0,0 +1,62 @@ +last_time = {} +calling = {} +calling_iterval = {} +call_after_interval = {} + +# Rate limit function call and don't allow to run in parallel (until callback is called) +window.RateLimitCb = (interval, fn, args=[]) -> + cb = -> # Callback when function finished + left = interval - (Date.now() - last_time[fn]) # Time life until next call + # console.log "CB, left", left, "Calling:", calling[fn] + if left <= 0 # No time left from rate limit interval + delete last_time[fn] + if calling[fn] # Function called within interval + RateLimitCb(interval, fn, calling[fn]) + delete calling[fn] + else # Time left from rate limit interval + setTimeout (-> + delete last_time[fn] + if calling[fn] # Function called within interval + RateLimitCb(interval, fn, calling[fn]) + delete calling[fn] + ), left + if last_time[fn] # Function called within interval + calling[fn] = args # Schedule call and update arguments + else # Not called within interval, call instantly + last_time[fn] = Date.now() + fn.apply(this, [cb, args...]) + + +window.RateLimit = (interval, fn) -> + if calling_iterval[fn] > interval + clearInterval calling[fn] + delete calling[fn] + + if not calling[fn] + call_after_interval[fn] = false + fn() # First call is not delayed + calling_iterval[fn] = interval + calling[fn] = setTimeout (-> + if call_after_interval[fn] + fn() + delete calling[fn] + delete call_after_interval[fn] + ), interval + else # Called within iterval, delay the call + call_after_interval[fn] = true + + +### +window.s = Date.now() +window.load = (done, num) -> + console.log "Loading #{num}...", Date.now()-window.s + setTimeout (-> done()), 1000 + +RateLimit 500, window.load, [0] # Called instantly +RateLimit 500, window.load, [1] +setTimeout (-> RateLimit 500, window.load, [300]), 300 +setTimeout (-> RateLimit 500, window.load, [600]), 600 # Called after 1000ms +setTimeout (-> RateLimit 500, window.load, [1000]), 1000 +setTimeout (-> RateLimit 500, window.load, [1200]), 1200 # Called after 2000ms +setTimeout (-> RateLimit 500, window.load, [3000]), 3000 # Called after 3000ms +### \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/lib/Text.coffee b/plugins/UiFileManager/media/js/lib/Text.coffee new file mode 100644 index 00000000..a9338983 --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/Text.coffee @@ -0,0 +1,147 @@ +class Text + toColor: (text, saturation=30, lightness=50) -> + hash = 0 + for i in [0..text.length-1] + hash += text.charCodeAt(i)*i + hash = hash % 1777 + return "hsl(" + (hash % 360) + ",#{saturation}%,#{lightness}%)"; + + + renderMarked: (text, options={}) -> + options["gfm"] = true + options["breaks"] = true + options["sanitize"] = true + options["renderer"] = marked_renderer + text = marked(text, options) + return @fixHtmlLinks text + + emailLinks: (text) -> + return text.replace(/([a-zA-Z0-9]+)@zeroid.bit/g, "$1@zeroid.bit") + + # Convert zeronet html links to relaitve + fixHtmlLinks: (text) -> + if window.is_proxy + return text.replace(/href="http:\/\/(127.0.0.1|localhost):43110/g, 'href="http://zero') + else + return text.replace(/href="http:\/\/(127.0.0.1|localhost):43110/g, 'href="') + + # Convert a single link to relative + fixLink: (link) -> + if window.is_proxy + back = link.replace(/http:\/\/(127.0.0.1|localhost):43110/, 'http://zero') + return back.replace(/http:\/\/zero\/([^\/]+\.bit)/, "http://$1") # Domain links + else + return link.replace(/http:\/\/(127.0.0.1|localhost):43110/, '') + + toUrl: (text) -> + return text.replace(/[^A-Za-z0-9]/g, "+").replace(/[+]+/g, "+").replace(/[+]+$/, "") + + getSiteUrl: (address) -> + if window.is_proxy + if "." in address # Domain + return "http://"+address+"/" + else + return "http://zero/"+address+"/" + else + return "/"+address+"/" + + + fixReply: (text) -> + return text.replace(/(>.*\n)([^\n>])/gm, "$1\n$2") + + toBitcoinAddress: (text) -> + return text.replace(/[^A-Za-z0-9]/g, "") + + + jsonEncode: (obj) -> + return unescape(encodeURIComponent(JSON.stringify(obj))) + + jsonDecode: (obj) -> + return JSON.parse(decodeURIComponent(escape(obj))) + + fileEncode: (obj) -> + if typeof(obj) == "string" + return btoa(unescape(encodeURIComponent(obj))) + else + return btoa(unescape(encodeURIComponent(JSON.stringify(obj, undefined, '\t')))) + + utf8Encode: (s) -> + return unescape(encodeURIComponent(s)) + + utf8Decode: (s) -> + return decodeURIComponent(escape(s)) + + + distance: (s1, s2) -> + s1 = s1.toLocaleLowerCase() + s2 = s2.toLocaleLowerCase() + next_find_i = 0 + next_find = s2[0] + match = true + extra_parts = {} + for char in s1 + if char != next_find + if extra_parts[next_find_i] + extra_parts[next_find_i] += char + else + extra_parts[next_find_i] = char + else + next_find_i++ + next_find = s2[next_find_i] + + if extra_parts[next_find_i] + extra_parts[next_find_i] = "" # Extra chars on the end doesnt matter + extra_parts = (val for key, val of extra_parts) + if next_find_i >= s2.length + return extra_parts.length + extra_parts.join("").length + else + return false + + + parseQuery: (query) -> + params = {} + parts = query.split('&') + for part in parts + [key, val] = part.split("=") + if val + params[decodeURIComponent(key)] = decodeURIComponent(val) + else + params["url"] = decodeURIComponent(key) + return params + + encodeQuery: (params) -> + back = [] + if params.url + back.push(params.url) + for key, val of params + if not val or key == "url" + continue + back.push("#{encodeURIComponent(key)}=#{encodeURIComponent(val)}") + return back.join("&") + + highlight: (text, search) -> + if not text + return [""] + parts = text.split(RegExp(search, "i")) + back = [] + for part, i in parts + back.push(part) + if i < parts.length-1 + back.push(h("span.highlight", {key: i}, search)) + return back + + formatSize: (size) -> + if isNaN(parseInt(size)) + return "" + size_mb = size/1024/1024 + if size_mb >= 1000 + return (size_mb/1024).toFixed(1)+" GB" + else if size_mb >= 100 + return size_mb.toFixed(0)+" MB" + else if size/1024 >= 1000 + return size_mb.toFixed(2)+" MB" + else + return (parseInt(size)/1024).toFixed(2)+" KB" + +window.is_proxy = (document.location.host == "zero" or window.location.pathname == "/") +window.Text = new Text() diff --git a/plugins/UiFileManager/media/js/lib/Time.coffee b/plugins/UiFileManager/media/js/lib/Time.coffee new file mode 100644 index 00000000..7adf3d6b --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/Time.coffee @@ -0,0 +1,59 @@ +class Time + since: (timestamp) -> + now = +(new Date)/1000 + if timestamp > 1000000000000 # In ms + timestamp = timestamp/1000 + secs = now - timestamp + if secs < 60 + back = "Just now" + else if secs < 60*60 + minutes = Math.round(secs/60) + back = "" + minutes + " minutes ago" + else if secs < 60*60*24 + back = "#{Math.round(secs/60/60)} hours ago" + else if secs < 60*60*24*3 + back = "#{Math.round(secs/60/60/24)} days ago" + else + back = "on "+@date(timestamp) + back = back.replace(/^1 ([a-z]+)s/, "1 $1") # 1 days ago fix + return back + + dateIso: (timestamp=null) -> + if not timestamp + timestamp = window.Time.timestamp() + + if timestamp > 1000000000000 # In ms + timestamp = timestamp/1000 + tzoffset = (new Date()).getTimezoneOffset() * 60 + return (new Date((timestamp - tzoffset) * 1000)).toISOString().split("T")[0] + + date: (timestamp=null, format="short") -> + if not timestamp + timestamp = window.Time.timestamp() + + if timestamp > 1000000000000 # In ms + timestamp = timestamp/1000 + parts = (new Date(timestamp * 1000)).toString().split(" ") + if format == "short" + display = parts.slice(1, 4) + else if format == "day" + display = parts.slice(1, 3) + else if format == "month" + display = [parts[1], parts[3]] + else if format == "long" + display = parts.slice(1, 5) + return display.join(" ").replace(/( [0-9]{4})/, ",$1") + + weekDay: (timestamp) -> + if timestamp > 1000000000000 # In ms + timestamp = timestamp/1000 + return ["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"][ (new Date(timestamp * 1000)).getDay() ] + + timestamp: (date="") -> + if date == "now" or date == "" + return parseInt(+(new Date)/1000) + else + return parseInt(Date.parse(date)/1000) + + +window.Time = new Time \ No newline at end of file diff --git a/plugins/UiFileManager/media/js/lib/ZeroFrame.coffee b/plugins/UiFileManager/media/js/lib/ZeroFrame.coffee new file mode 100644 index 00000000..11512d16 --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/ZeroFrame.coffee @@ -0,0 +1,85 @@ +class ZeroFrame extends Class + constructor: (url) -> + @url = url + @waiting_cb = {} + @wrapper_nonce = document.location.href.replace(/.*wrapper_nonce=([A-Za-z0-9]+).*/, "$1") + @connect() + @next_message_id = 1 + @history_state = {} + @init() + + + init: -> + @ + + + connect: -> + @target = window.parent + window.addEventListener("message", @onMessage, false) + @cmd("innerReady") + + # Save scrollTop + window.addEventListener "beforeunload", (e) => + @log "save scrollTop", window.pageYOffset + @history_state["scrollTop"] = window.pageYOffset + @cmd "wrapperReplaceState", [@history_state, null] + + # Restore scrollTop + @cmd "wrapperGetState", [], (state) => + @history_state = state if state? + @log "restore scrollTop", state, window.pageYOffset + if window.pageYOffset == 0 and state + window.scroll(window.pageXOffset, state.scrollTop) + + + onMessage: (e) => + message = e.data + cmd = message.cmd + if cmd == "response" + if @waiting_cb[message.to]? + @waiting_cb[message.to](message.result) + else + @log "Websocket callback not found:", message + else if cmd == "wrapperReady" # Wrapper inited later + @cmd("innerReady") + else if cmd == "ping" + @response message.id, "pong" + else if cmd == "wrapperOpenedWebsocket" + @onOpenWebsocket() + else if cmd == "wrapperClosedWebsocket" + @onCloseWebsocket() + else + @onRequest cmd, message.params + + + onRequest: (cmd, message) => + @log "Unknown request", message + + + response: (to, result) -> + @send {"cmd": "response", "to": to, "result": result} + + + cmd: (cmd, params={}, cb=null) -> + @send {"cmd": cmd, "params": params}, cb + + + send: (message, cb=null) -> + message.wrapper_nonce = @wrapper_nonce + message.id = @next_message_id + @next_message_id += 1 + @target.postMessage(message, "*") + if cb + @waiting_cb[message.id] = cb + + + onOpenWebsocket: => + @log "Websocket open" + + + onCloseWebsocket: => + @log "Websocket close" + + + +window.ZeroFrame = ZeroFrame diff --git a/plugins/UiFileManager/media/js/lib/maquette.js b/plugins/UiFileManager/media/js/lib/maquette.js new file mode 100644 index 00000000..84d14471 --- /dev/null +++ b/plugins/UiFileManager/media/js/lib/maquette.js @@ -0,0 +1,770 @@ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['exports'], factory); + } else if (typeof exports === 'object' && typeof exports.nodeName !== 'string') { + // CommonJS + factory(exports); + } else { + // Browser globals + factory(root.maquette = {}); + } +}(this, function (exports) { + 'use strict'; + ; + ; + ; + ; + var NAMESPACE_W3 = 'http://www.w3.org/'; + var NAMESPACE_SVG = NAMESPACE_W3 + '2000/svg'; + var NAMESPACE_XLINK = NAMESPACE_W3 + '1999/xlink'; + // Utilities + var emptyArray = []; + var extend = function (base, overrides) { + var result = {}; + Object.keys(base).forEach(function (key) { + result[key] = base[key]; + }); + if (overrides) { + Object.keys(overrides).forEach(function (key) { + result[key] = overrides[key]; + }); + } + return result; + }; + // Hyperscript helper functions + var same = function (vnode1, vnode2) { + if (vnode1.vnodeSelector !== vnode2.vnodeSelector) { + return false; + } + if (vnode1.properties && vnode2.properties) { + if (vnode1.properties.key !== vnode2.properties.key) { + return false; + } + return vnode1.properties.bind === vnode2.properties.bind; + } + return !vnode1.properties && !vnode2.properties; + }; + var toTextVNode = function (data) { + return { + vnodeSelector: '', + properties: undefined, + children: undefined, + text: data.toString(), + domNode: null + }; + }; + var appendChildren = function (parentSelector, insertions, main) { + for (var i = 0; i < insertions.length; i++) { + var item = insertions[i]; + if (Array.isArray(item)) { + appendChildren(parentSelector, item, main); + } else { + if (item !== null && item !== undefined) { + if (!item.hasOwnProperty('vnodeSelector')) { + item = toTextVNode(item); + } + main.push(item); + } + } + } + }; + // Render helper functions + var missingTransition = function () { + throw new Error('Provide a transitions object to the projectionOptions to do animations'); + }; + var DEFAULT_PROJECTION_OPTIONS = { + namespace: undefined, + eventHandlerInterceptor: undefined, + styleApplyer: function (domNode, styleName, value) { + // Provides a hook to add vendor prefixes for browsers that still need it. + domNode.style[styleName] = value; + }, + transitions: { + enter: missingTransition, + exit: missingTransition + } + }; + var applyDefaultProjectionOptions = function (projectorOptions) { + return extend(DEFAULT_PROJECTION_OPTIONS, projectorOptions); + }; + var checkStyleValue = function (styleValue) { + if (typeof styleValue !== 'string') { + throw new Error('Style values must be strings'); + } + }; + var setProperties = function (domNode, properties, projectionOptions) { + if (!properties) { + return; + } + var eventHandlerInterceptor = projectionOptions.eventHandlerInterceptor; + var propNames = Object.keys(properties); + var propCount = propNames.length; + for (var i = 0; i < propCount; i++) { + var propName = propNames[i]; + /* tslint:disable:no-var-keyword: edge case */ + var propValue = properties[propName]; + /* tslint:enable:no-var-keyword */ + if (propName === 'className') { + throw new Error('Property "className" is not supported, use "class".'); + } else if (propName === 'class') { + if (domNode.className) { + // May happen if classes is specified before class + domNode.className += ' ' + propValue; + } else { + domNode.className = propValue; + } + } else if (propName === 'classes') { + // object with string keys and boolean values + var classNames = Object.keys(propValue); + var classNameCount = classNames.length; + for (var j = 0; j < classNameCount; j++) { + var className = classNames[j]; + if (propValue[className]) { + domNode.classList.add(className); + } + } + } else if (propName === 'styles') { + // object with string keys and string (!) values + var styleNames = Object.keys(propValue); + var styleCount = styleNames.length; + for (var j = 0; j < styleCount; j++) { + var styleName = styleNames[j]; + var styleValue = propValue[styleName]; + if (styleValue) { + checkStyleValue(styleValue); + projectionOptions.styleApplyer(domNode, styleName, styleValue); + } + } + } else if (propName === 'key') { + continue; + } else if (propValue === null || propValue === undefined) { + continue; + } else { + var type = typeof propValue; + if (type === 'function') { + if (propName.lastIndexOf('on', 0) === 0) { + if (eventHandlerInterceptor) { + propValue = eventHandlerInterceptor(propName, propValue, domNode, properties); // intercept eventhandlers + } + if (propName === 'oninput') { + (function () { + // record the evt.target.value, because IE and Edge sometimes do a requestAnimationFrame between changing value and running oninput + var oldPropValue = propValue; + propValue = function (evt) { + evt.target['oninput-value'] = evt.target.value; + // may be HTMLTextAreaElement as well + oldPropValue.apply(this, [evt]); + }; + }()); + } + domNode[propName] = propValue; + } + } else if (type === 'string' && propName !== 'value' && propName !== 'innerHTML') { + if (projectionOptions.namespace === NAMESPACE_SVG && propName === 'href') { + domNode.setAttributeNS(NAMESPACE_XLINK, propName, propValue); + } else { + domNode.setAttribute(propName, propValue); + } + } else { + domNode[propName] = propValue; + } + } + } + }; + var updateProperties = function (domNode, previousProperties, properties, projectionOptions) { + if (!properties) { + return; + } + var propertiesUpdated = false; + var propNames = Object.keys(properties); + var propCount = propNames.length; + for (var i = 0; i < propCount; i++) { + var propName = propNames[i]; + // assuming that properties will be nullified instead of missing is by design + var propValue = properties[propName]; + var previousValue = previousProperties[propName]; + if (propName === 'class') { + if (previousValue !== propValue) { + throw new Error('"class" property may not be updated. Use the "classes" property for conditional css classes.'); + } + } else if (propName === 'classes') { + var classList = domNode.classList; + var classNames = Object.keys(propValue); + var classNameCount = classNames.length; + for (var j = 0; j < classNameCount; j++) { + var className = classNames[j]; + var on = !!propValue[className]; + var previousOn = !!previousValue[className]; + if (on === previousOn) { + continue; + } + propertiesUpdated = true; + if (on) { + classList.add(className); + } else { + classList.remove(className); + } + } + } else if (propName === 'styles') { + var styleNames = Object.keys(propValue); + var styleCount = styleNames.length; + for (var j = 0; j < styleCount; j++) { + var styleName = styleNames[j]; + var newStyleValue = propValue[styleName]; + var oldStyleValue = previousValue[styleName]; + if (newStyleValue === oldStyleValue) { + continue; + } + propertiesUpdated = true; + if (newStyleValue) { + checkStyleValue(newStyleValue); + projectionOptions.styleApplyer(domNode, styleName, newStyleValue); + } else { + projectionOptions.styleApplyer(domNode, styleName, ''); + } + } + } else { + if (!propValue && typeof previousValue === 'string') { + propValue = ''; + } + if (propName === 'value') { + if (domNode[propName] !== propValue && domNode['oninput-value'] !== propValue) { + domNode[propName] = propValue; + // Reset the value, even if the virtual DOM did not change + domNode['oninput-value'] = undefined; + } + // else do not update the domNode, otherwise the cursor position would be changed + if (propValue !== previousValue) { + propertiesUpdated = true; + } + } else if (propValue !== previousValue) { + var type = typeof propValue; + if (type === 'function') { + throw new Error('Functions may not be updated on subsequent renders (property: ' + propName + '). Hint: declare event handler functions outside the render() function.'); + } + if (type === 'string' && propName !== 'innerHTML') { + if (projectionOptions.namespace === NAMESPACE_SVG && propName === 'href') { + domNode.setAttributeNS(NAMESPACE_XLINK, propName, propValue); + } else { + domNode.setAttribute(propName, propValue); + } + } else { + if (domNode[propName] !== propValue) { + domNode[propName] = propValue; + } + } + propertiesUpdated = true; + } + } + } + return propertiesUpdated; + }; + var findIndexOfChild = function (children, sameAs, start) { + if (sameAs.vnodeSelector !== '') { + // Never scan for text-nodes + for (var i = start; i < children.length; i++) { + if (same(children[i], sameAs)) { + return i; + } + } + } + return -1; + }; + var nodeAdded = function (vNode, transitions) { + if (vNode.properties) { + var enterAnimation = vNode.properties.enterAnimation; + if (enterAnimation) { + if (typeof enterAnimation === 'function') { + enterAnimation(vNode.domNode, vNode.properties); + } else { + transitions.enter(vNode.domNode, vNode.properties, enterAnimation); + } + } + } + }; + var nodeToRemove = function (vNode, transitions) { + var domNode = vNode.domNode; + if (vNode.properties) { + var exitAnimation = vNode.properties.exitAnimation; + if (exitAnimation) { + domNode.style.pointerEvents = 'none'; + var removeDomNode = function () { + if (domNode.parentNode) { + domNode.parentNode.removeChild(domNode); + } + }; + if (typeof exitAnimation === 'function') { + exitAnimation(domNode, removeDomNode, vNode.properties); + return; + } else { + transitions.exit(vNode.domNode, vNode.properties, exitAnimation, removeDomNode); + return; + } + } + } + if (domNode.parentNode) { + domNode.parentNode.removeChild(domNode); + } + }; + var checkDistinguishable = function (childNodes, indexToCheck, parentVNode, operation) { + var childNode = childNodes[indexToCheck]; + if (childNode.vnodeSelector === '') { + return; // Text nodes need not be distinguishable + } + var properties = childNode.properties; + var key = properties ? properties.key === undefined ? properties.bind : properties.key : undefined; + if (!key) { + for (var i = 0; i < childNodes.length; i++) { + if (i !== indexToCheck) { + var node = childNodes[i]; + if (same(node, childNode)) { + if (operation === 'added') { + throw new Error(parentVNode.vnodeSelector + ' had a ' + childNode.vnodeSelector + ' child ' + 'added, but there is now more than one. You must add unique key properties to make them distinguishable.'); + } else { + throw new Error(parentVNode.vnodeSelector + ' had a ' + childNode.vnodeSelector + ' child ' + 'removed, but there were more than one. You must add unique key properties to make them distinguishable.'); + } + } + } + } + } + }; + var createDom; + var updateDom; + var updateChildren = function (vnode, domNode, oldChildren, newChildren, projectionOptions) { + if (oldChildren === newChildren) { + return false; + } + oldChildren = oldChildren || emptyArray; + newChildren = newChildren || emptyArray; + var oldChildrenLength = oldChildren.length; + var newChildrenLength = newChildren.length; + var transitions = projectionOptions.transitions; + var oldIndex = 0; + var newIndex = 0; + var i; + var textUpdated = false; + while (newIndex < newChildrenLength) { + var oldChild = oldIndex < oldChildrenLength ? oldChildren[oldIndex] : undefined; + var newChild = newChildren[newIndex]; + if (oldChild !== undefined && same(oldChild, newChild)) { + textUpdated = updateDom(oldChild, newChild, projectionOptions) || textUpdated; + oldIndex++; + } else { + var findOldIndex = findIndexOfChild(oldChildren, newChild, oldIndex + 1); + if (findOldIndex >= 0) { + // Remove preceding missing children + for (i = oldIndex; i < findOldIndex; i++) { + nodeToRemove(oldChildren[i], transitions); + checkDistinguishable(oldChildren, i, vnode, 'removed'); + } + textUpdated = updateDom(oldChildren[findOldIndex], newChild, projectionOptions) || textUpdated; + oldIndex = findOldIndex + 1; + } else { + // New child + createDom(newChild, domNode, oldIndex < oldChildrenLength ? oldChildren[oldIndex].domNode : undefined, projectionOptions); + nodeAdded(newChild, transitions); + checkDistinguishable(newChildren, newIndex, vnode, 'added'); + } + } + newIndex++; + } + if (oldChildrenLength > oldIndex) { + // Remove child fragments + for (i = oldIndex; i < oldChildrenLength; i++) { + nodeToRemove(oldChildren[i], transitions); + checkDistinguishable(oldChildren, i, vnode, 'removed'); + } + } + return textUpdated; + }; + var addChildren = function (domNode, children, projectionOptions) { + if (!children) { + return; + } + for (var i = 0; i < children.length; i++) { + createDom(children[i], domNode, undefined, projectionOptions); + } + }; + var initPropertiesAndChildren = function (domNode, vnode, projectionOptions) { + addChildren(domNode, vnode.children, projectionOptions); + // children before properties, needed for value property of