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 00000000..27d0aa81
Binary files /dev/null and b/plugins/UiFileManager/media/img/loading.gif differ
diff --git a/plugins/UiFileManager/media/js/FileEditor.coffee b/plugins/UiFileManager/media/js/FileEditor.coffee
new file mode 100644
index 00000000..24275df9
--- /dev/null
+++ b/plugins/UiFileManager/media/js/FileEditor.coffee
@@ -0,0 +1,171 @@
+class FileEditor extends Class
+ constructor: (@inner_path) ->
+ @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