From e6199ce42b3713ec2561bbefa76324b305c8fbb0 Mon Sep 17 00:00:00 2001
From: Steve Eynon <3326741+SlimerDude@users.noreply.github.com>
Date: Mon, 7 Sep 2020 12:26:16 +0100
Subject: [PATCH] Ta Daa!!!
---
.buildpath | 5 +
.classpath | 6 +
.gitignore | 5 +
.project | 23 ++++
build.fan | 24 ++++
doc/pod.fandoc | 4 +
fan/appkit/Checkbox.fan | 63 ++++++++++
fan/appkit/Link.fan | 41 +++++++
fan/appkit/Modal.fan | 248 +++++++++++++++++++++++++++++++++++++++
fan/appkit/TextField.fan | 99 ++++++++++++++++
fan/core/AppInit.fan | 40 +++++++
fan/core/ErrHandler.fan | 57 +++++++++
fan/core/MiniIoc.fan | 90 ++++++++++++++
fan/util/JsUtil.fan | 46 ++++++++
js/JsUtilPeer.js | 53 +++++++++
15 files changed, 804 insertions(+)
create mode 100644 .buildpath
create mode 100644 .classpath
create mode 100644 .gitignore
create mode 100644 .project
create mode 100644 build.fan
create mode 100644 doc/pod.fandoc
create mode 100644 fan/appkit/Checkbox.fan
create mode 100644 fan/appkit/Link.fan
create mode 100644 fan/appkit/Modal.fan
create mode 100644 fan/appkit/TextField.fan
create mode 100644 fan/core/AppInit.fan
create mode 100644 fan/core/ErrHandler.fan
create mode 100644 fan/core/MiniIoc.fan
create mode 100644 fan/util/JsUtil.fan
create mode 100644 js/JsUtilPeer.js
diff --git a/.buildpath b/.buildpath
new file mode 100644
index 0000000..d077e0c
--- /dev/null
+++ b/.buildpath
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/.classpath b/.classpath
new file mode 100644
index 0000000..c66ffb2
--- /dev/null
+++ b/.classpath
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..da16ec6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/.hg/
+/.skyspark/
+/bin/
+/build/
+/sky/
diff --git a/.project b/.project
new file mode 100644
index 0000000..b966cfa
--- /dev/null
+++ b/.project
@@ -0,0 +1,23 @@
+
+
+ App Kit
+
+
+
+
+
+ org.eclipse.jdt.core.javabuilder
+
+
+
+
+ org.eclipse.dltk.core.scriptbuilder
+
+
+
+
+
+ com.xored.fanide.core.nature
+ org.eclipse.jdt.core.javanature
+
+
diff --git a/build.fan b/build.fan
new file mode 100644
index 0000000..26b4996
--- /dev/null
+++ b/build.fan
@@ -0,0 +1,24 @@
+using build::BuildPod
+
+class Build : BuildPod {
+
+ new make() {
+ podName = "afAppKit"
+ summary = "My Awesome appKit project"
+ version = Version("0.0.1")
+
+ meta = [
+ "pod.dis" : "App Kit",
+ ]
+
+ depends = [
+ // ---- Fantom Core -----------------
+ "sys 1.0.73 - 1.0",
+ "dom 1.0.73 - 1.0",
+ ]
+
+ srcDirs = [`fan/`, `fan/appkit/`, `fan/core/`, `fan/util/`]
+ resDirs = [`doc/`]
+ jsDirs = [`js/`]
+ }
+}
diff --git a/doc/pod.fandoc b/doc/pod.fandoc
new file mode 100644
index 0000000..da2ff7e
--- /dev/null
+++ b/doc/pod.fandoc
@@ -0,0 +1,4 @@
+
+Overview
+********
+afAppKit - My Awesome appKit project
diff --git a/fan/appkit/Checkbox.fan b/fan/appkit/Checkbox.fan
new file mode 100644
index 0000000..15680c2
--- /dev/null
+++ b/fan/appkit/Checkbox.fan
@@ -0,0 +1,63 @@
+using dom
+
+**
+** Checkbox displays a checkbox that can be toggled on and off.
+**
+** - domkit 1.0.75
+@Js class Checkbox {
+
+ Elem elem { private set }
+
+ private new _make(Elem elem) {
+ this.elem = elem
+
+ if (elem.tagName != "input" && elem["type"] != "checkbox")
+ throw ArgErr("Elem not an input checkbox: ${elem.html}")
+
+ elem.onEvent("change", false) |e| {
+ fireAction(e)
+ }
+ }
+
+ static new fromSelector(Str selector) {
+ elem := Win.cur.doc.querySelector(selector)
+ if (elem == null) throw Err("Could not find Checkbox: ${selector}")
+ return fromElem(elem)
+ }
+
+ static new fromElem(Elem elem) {
+ if (elem.prop(Checkbox#.qname) == null)
+ elem.setProp(Checkbox#.qname, Checkbox._make(elem))
+ return elem.prop(Checkbox#.qname)
+ }
+
+ ** Set to 'true' to set field to readonly mode.
+ Bool ro {
+ get { elem->readOnly }
+ set { elem->readOnly = it }
+ }
+
+ ** Get or set indeterminate flag.
+ Bool indeterminate {
+ get { elem->indeterminate }
+ set { elem->indeterminate = it }
+ }
+
+ ** Name of input.
+ Str? name {
+ get { elem->name }
+ set { elem->name = it }
+ }
+
+ ** Value of checked.
+ Bool checked {
+ get { elem->checked }
+ set { elem->checked = it }
+ }
+
+ ** Callback when 'enter' key is pressed.
+ Void onAction(|This| f) { this.cbAction = f }
+
+ internal Void fireAction(Event? e) { cbAction?.call(this) }
+ private Func? cbAction := null
+}
\ No newline at end of file
diff --git a/fan/appkit/Link.fan b/fan/appkit/Link.fan
new file mode 100644
index 0000000..c83bdc7
--- /dev/null
+++ b/fan/appkit/Link.fan
@@ -0,0 +1,41 @@
+using dom
+
+**
+** Hyperlink anchor element
+**
+** - domkit 1.0.75
+@Js class Link {
+
+ Elem elem { private set }
+
+ private new _make(Elem elem) {
+ this.elem = elem
+
+ if (elem.tagName != "a")
+ throw ArgErr("Elem not an anchor: ${elem.html}")
+ }
+
+ static new fromSelector(Str selector) {
+ elem := Win.cur.doc.querySelector(selector)
+ if (elem == null) throw Err("Could not find Link: ${selector}")
+ return fromElem(elem)
+ }
+
+ static new fromElem(Elem elem) {
+ if (elem.prop(Link#.qname) == null)
+ elem.setProp(Link#.qname, Link._make(elem))
+ return elem.prop(Link#.qname)
+ }
+
+ ** The target attribute specifies where to open the linked document.
+ Str target {
+ get { this->target }
+ set { this->target = it }
+ }
+
+ ** URI to hyperlink to.
+ Uri url {
+ get { Uri.decode(elem->href ?: "") }
+ set { elem->href = it.encode }
+ }
+}
\ No newline at end of file
diff --git a/fan/appkit/Modal.fan b/fan/appkit/Modal.fan
new file mode 100644
index 0000000..a9551ff
--- /dev/null
+++ b/fan/appkit/Modal.fan
@@ -0,0 +1,248 @@
+using dom::Doc
+using dom::Key
+using dom::Win
+using dom::Elem
+using dom::Event
+using dom::CssDim
+
+@Js class Modal {
+ Duration transistionDur := 200ms
+ Str:Str transitionFrom := Str:Str[
+ "transform" : "scale(0.875)",
+ "opacity" : "0"
+ ]
+ Str:Str transitionTo := Str:Str[
+ "transform" : "scale(1)",
+ "opacity" : "1"
+ ]
+
+ private Elem? mask
+ private Func? cbOpened
+ private Func? cbClosed
+ private Func? cbKeyDown
+ Bool isOpen { private set }
+
+ Elem elem { private set }
+
+ private new _make(Elem elem) {
+ this.elem = elem
+
+ elem.onEvent("keydown", false) |e| {
+ // Enter for closing "Wrong - Try Again!" Mini-Modals
+ if (e.key == Key.esc || (elem.querySelector("form") == null && e.key == Key.enter))
+ close
+ else
+ cbKeyDown?.call(e)
+ }
+
+ elem.onEvent("click", false) |e| {
+ if (e.target == elem) {
+ // ignore background clicks on modals with Forms
+ // Emma has a habit (on Chrome) of *over-selecting* text which closes the dialog!
+ if (elem.querySelector("form") == null)
+ close
+ }
+ }
+
+ elem.querySelectorAll("[data-dismiss=modal]").each {
+ it.onEvent("click", false) { close }
+ }
+ }
+
+ static new fromSelector(Str selector) {
+ elem := doc.querySelector(selector)
+ if (elem == null) throw Err("Could not find Modal: ${selector}")
+ return fromElem(elem)
+ }
+
+ static new fromElem(Elem elem) {
+ if (elem.prop(Modal#.qname) == null)
+ elem.setProp(Modal#.qname, Modal._make(elem))
+ return elem.prop(Modal#.qname)
+ }
+
+ static Modal createInfoDialog(Str title, Obj body) {
+ // todo style info dialogs more
+ createDialog(title, body)
+ }
+
+ static Modal createErrDialog(Str title, Obj body) {
+ // todo style err dialogs more
+ createDialog(title, body)
+ }
+
+ static Modal createMiniModal(Obj body, Bool spinIn) {
+ modal := div("modal") {
+ it["tabindex"] = "-1"
+ it["role"] = "dialog"
+ div("modalDialog modalDialog-centered") {
+ div("modalContent") {
+ it["data-dismiss"] = "modal" // let any click close these
+ it.style->cursor = "pointer"
+ div("modalBody") {
+ it.add(Elem("button") {
+ it.style.addClass("close")
+ it["type"] = "button"
+ it["data-dismiss"] = "modal"
+ it["aria-label"] = "Close"
+ Elem("span") {
+ it["aria-hidden"] = "true"
+ it.html = "×"
+ },
+ })
+ if (body is Elem)
+ it.add(body)
+ if (body is Str)
+ it.add(Elem("span") { it.html = body.toStr.replace("\n", "
") })
+ },
+ },
+ },
+ }
+ doc.body.add(modal)
+ return fromElem(modal) {
+ if (spinIn) {
+ degTo := (-15..15).random
+ degFrom := degTo + (spinIn ? 270 : 0)
+ it.transistionDur = spinIn ? 350ms : 200ms
+ it.transitionTo ["transform"] = "scale(1.0) rotate(${degTo}deg)"
+ it.transitionFrom["transform"] = "scale(0.5) rotate(${degFrom}deg)"
+ }
+ it.onClosed {
+ // it's a one-time-shot baby!
+ doc.body.remove(modal)
+ }
+ }
+ }
+
+ static Modal createDialog(Str title, Obj body) {
+ modal := div("modal") {
+ it["tabindex"] = "-1"
+ it["role"] = "dialog"
+ div("modalDialog modalDialog-centered") {
+ div("modalContent") {
+ div("modalHeader") {
+ div("modalTitle") {
+ it.text = title
+ Elem("button") {
+ it.style.addClass("close")
+ it["type"] = "button"
+ it["data-dismiss"] = "modal"
+ it["aria-label"] = "Close"
+ Elem("span") {
+ it["aria-hidden"] = "true"
+ it.html = "×"
+ },
+ },
+ },
+ },
+ div("modalBody") {
+ if (body is Elem)
+ it.add(body)
+ if (body is Str)
+ it.add(Elem("span") { it.html = body.toStr.replace("\n", "
") })
+ div("modalButtons") {
+ Elem("button") {
+ it.style.addClass("btn link")
+ it["type"] = "button"
+ it["data-dismiss"] = "modal"
+ it["aria-label"] = "Close"
+ it.text = "Close"
+ },
+ },
+ },
+ },
+ },
+ }
+ doc.body.add(modal)
+ return fromElem(modal) {
+ it.onClosed {
+ // it's a one-time-shot baby!
+ doc.body.remove(modal)
+ }
+ }
+ }
+
+ private static Elem div(Str css) {
+ Elem("div") { it.style.addClass(css) }
+ }
+
+ This open() {
+ // we remove the body's scrollbar so the body can't be scrolled behind the modal
+ // but that makes the body wider and re-flows the content, so we add extra body padding to keep everything in place
+ JsUtil.addScrollbarWidth(doc.body, "padding-right")
+
+ // the mask is just for show - it's the modal that's active and receives all the events
+ mask = Elem("div") { it.style.addClass("modalBackdrop fade show") }
+ doc.body.style.addClass("modal-open")
+ doc.body.add(mask)
+ mask.style->opacity = "0"
+ mask.transition([
+ "opacity" : "0.5"
+ ], ["transition-timing-function":"ease-out"], transistionDur)
+
+ elem.style->display = "block"
+ elem.removeAttr ("aria-hidden")
+ elem.setAttr ("aria-modal", "true")
+ elem.style.addClass ("show")
+
+ transitionFrom.each |val, prop| { elem.style[prop] = val }
+ elem.transition(transitionTo, ["transition-timing-function":"ease-out"], transistionDur) {
+ fireOpened // this could return true to prevent the modal from showing...? meh.
+ elem.focus
+ }
+
+ isOpen = true
+ return this
+ }
+
+ Void close() {
+ // don't allow double closes - it results in an NPE
+ if (isOpen == false) return
+
+ elem.transition(transitionFrom, ["transition-timing-function":"ease-in"], transistionDur) {
+ elem.style->display = "none"
+ elem.setAttr ("aria-hidden", "true")
+ elem.removeAttr ("aria-modal")
+ elem.style.removeClass ("show")
+ }
+
+ mask.transition([
+ "opacity" :"0"
+ ], ["transition-timing-function":"ease-in"], transistionDur) {
+ doc.body.style.removeClass("modal-open")
+ JsUtil.removeScrollbarWidth(doc.body, "padding-right")
+ doc.body.remove(mask)
+ mask = null
+
+ // make sure the Modal closes before we fire the next handler
+ win.setTimeout(10ms) { fireClosed }
+ }
+
+ isOpen = false
+ }
+
+ ** Callback when a key is pressed while Dialog is open, including
+ ** events that where dispatched outside the dialog.
+ Void onKeyDown(|Event e|? newFn) {
+ cbKeyDown = newFn
+ }
+
+ ** Callback when dialog is opened.
+ Void onOpened(|This|? newFn) {
+ cbOpened = newFn
+ }
+
+ ** Callback when popup is closed.
+ Void onClosed(|This|? newFn) {
+ cbClosed = newFn
+ // this is actually unhelpful - but if ever needed, should be called addOnClosed()
+// oldFn := cbClosed
+// cbClosed = oldFn == null ? newFn : |Modal th| { oldFn(th); newFn(th) }
+ }
+
+ private Void fireOpened() { cbOpened?.call(this) }
+ private Void fireClosed() { cbClosed?.call(this) }
+
+ private static Win win() { Win.cur }
+ private static Doc doc() { win.doc }
+}
diff --git a/fan/appkit/TextField.fan b/fan/appkit/TextField.fan
new file mode 100644
index 0000000..edf2183
--- /dev/null
+++ b/fan/appkit/TextField.fan
@@ -0,0 +1,99 @@
+using dom
+
+**
+** Text field input element.
+**
+** - domkit 1.0.75
+@Js class TextField {
+
+ Elem elem { private set }
+
+ private new _make(Elem elem) {
+ this.elem = elem
+
+ if (elem.tagName != "input" && elem.tagName != "textarea")
+ // note the "type" attr may be blank, text, email, number, ...
+ throw ArgErr("Elem not an input: ${elem.html}")
+
+ elem.onEvent("input", false) |e| {
+ checkUpdate
+ fireModify(e)
+ }
+ elem.onEvent("keydown", false) |e| {
+ if (e.key == Key.enter) fireAction(e)
+ }
+ }
+
+ static new fromSelector(Str selector) {
+ elem := Win.cur.doc.querySelector(selector)
+ if (elem == null) throw Err("Could not find TextField: ${selector}")
+ return fromElem(elem)
+ }
+
+ static new fromElem(Elem elem) {
+ if (elem.prop(TextField#.qname) == null)
+ elem.setProp(TextField#.qname, TextField._make(elem))
+ return elem.prop(TextField#.qname)
+ }
+
+ ** Preferred width of field in columns, or 'null' for default.
+ Int? cols {
+ get { elem->size }
+ set { elem->size = it }
+ }
+
+ ** Hint that is displayed in the field before a user enters a
+ ** value that describes the expected input, or 'null' for no
+ ** placeholder text.
+ Str? placeholder {
+ get { elem->placeholder }
+ set { elem->placeholder = it }
+ }
+
+ ** Set to 'true' to set field to readonly mode.
+ Bool ro {
+ get { elem->readOnly }
+ set { elem->readOnly = it }
+ }
+
+ ** Set to 'true' to mask characters inputed into field.
+ Bool password {
+ get { elem->type == "password" }
+ set { elem->type = it ? "password" : "text" }
+ }
+
+ ** Name of input.
+ Str? name {
+ get { elem->name }
+ set { elem->name = it }
+ }
+
+ ** Value of text field.
+ Str value {
+ get { elem->value }
+ set { elem->value = it; checkUpdate }
+ }
+
+ ** Callback when value is modified by user.
+ Void onModify(|This| f) { this.cbModify = f }
+
+ ** Callback when 'enter' key is pressed.
+ Void onAction(|This| f) { this.cbAction = f }
+
+ ** Select given range of text
+ Void select(Int start, Int end) {
+ elem->selectionStart = start
+ elem->selectionEnd = end
+ }
+
+ internal Void fireAction(Event? e) { cbAction?.call(this) }
+ private Func? cbAction := null
+
+ internal Void fireModify(Event? e) { cbModify?.call(this) }
+ private Func? cbModify := null
+
+ // framework use only
+ private Void checkUpdate() {
+// if (parent is Combo) ((Combo) parent).update(val.trim)
+ }
+}
\ No newline at end of file
diff --git a/fan/core/AppInit.fan b/fan/core/AppInit.fan
new file mode 100644
index 0000000..af531bc
--- /dev/null
+++ b/fan/core/AppInit.fan
@@ -0,0 +1,40 @@
+using dom::Elem
+using dom::Doc
+using dom::Win
+
+@Js class AppInit {
+
+ private ErrHandler errHandler := ErrHandler()
+
+ ** This is the main entry point to the Js App
+ Void init(Type appType, Str:Obj? config) {
+ try doInit(appType, config)
+ catch (Err cause) {
+ errHandler.log.err("Could not initialise page")
+ errHandler.onError(cause)
+ }
+ }
+
+ Void doInit(Type appType, Str:Obj? config) {
+ appNom := config["appName" ]?.toStr ?: AppInit#.pod.name
+ appVer := config["appVersion"]?.toStr ?: AppInit#.pod.version.toStr
+ logLogo(appNom, appVer)
+
+ injector := MiniIoc(null, config)
+ appPage := injector.build(appType)
+ appPage.typeof.method("init", false)?.callOn(appPage, null)
+ }
+
+ private Void logLogo(Str name, Str ver) {
+ tag := "powers ${name} v${ver}"
+ logo := Str<| _____ __ _____ __
+ / ___/_ _____/ /_____ ______ / ___/_ ____/ /_________ __ __
+ / __/ _ \/ _ / __/ _ / , , / / __/ _ \/ __/ __/ _ / __|/ // /
+ /_/ \_,_/_//_/\__/____/_/_/_/ /_/ \_,_/___/\__/____/_/ \_, /
+ /___/ |>
+ lines := logo.split('\n', false)
+ lines[-1] = StrBuf().add(lines[-1]).replaceRange(57-tag.size..<57, tag).toStr
+ text := lines.join("\n")
+ typeof.pod.log.info("\n${text}\n\n")
+ }
+}
diff --git a/fan/core/ErrHandler.fan b/fan/core/ErrHandler.fan
new file mode 100644
index 0000000..136d763
--- /dev/null
+++ b/fan/core/ErrHandler.fan
@@ -0,0 +1,57 @@
+using dom::Elem
+using dom::Event
+using dom::Win
+
+@Js class ErrHandler {
+ static const Str errTitle := "Shazbot! The computer reported an error!"
+ static const Str errMsg := "Don't worry, it's not your fault - it's ours!\n\nSteve can fix it (he can fix anything!) but he needs to know about it first. Just drop us a quick email telling us what happened and Steve will do his best.\n\nCheers,\n\nFantom Factory.".replace("\n", "
")
+
+ new make(|This|? f := null) { f?.call(this) }
+
+ Void onClick(Obj? obj, |Event, Elem| fn) {
+ onEvent("click", obj, fn)
+ }
+
+ Void onEvent(Str type, Obj? obj, |Event, Elem| fn) {
+ if (obj == null) return
+
+ elems := null as Elem[]
+ if (elems == null && obj is List)
+ elems = obj
+ if (elems == null && obj is Elem)
+ elems = Elem[obj]
+ if (elems == null)
+ elems = Win.cur.doc.querySelectorAll(obj.toStr)
+
+ elems.each |elem| {
+ elem.onEvent(type, false) |event| {
+ try fn(event, elem)
+ catch (Err cause) {
+ log.err("Error in '$type' event handler")
+ onError(cause)
+ }
+ }
+ }
+ }
+
+ ** Define |Obj| to utilise it-block funcs
+ Void wrap(|Obj?| fn) {
+ try fn(null)
+ catch (Err err) onError(err)
+ }
+
+ Void onError(Err? cause := null) {
+ log.err("As caught by ErrHandler", cause)
+ openModal(errTitle, errMsg)
+ if (cause != null)
+ throw cause
+ }
+
+ Log log() { this.typeof.pod.log }
+
+ Void openModal(Str title, Obj body) {
+ // FIXME open modal
+// Modal.createErrDialog(title, body).open
+ dom::Win.cur.alert(body)
+ }
+}
diff --git a/fan/core/MiniIoc.fan b/fan/core/MiniIoc.fan
new file mode 100644
index 0000000..61c4cb3
--- /dev/null
+++ b/fan/core/MiniIoc.fan
@@ -0,0 +1,90 @@
+
+** A very poor man's IoC that's concerned with object construction only.
+** + Config field injection (keyed off.last.dot.name)
+**
+** IoC limitations:
+** - only 1 instance of any type may exist
+** - no field injection; it-block & ctor construction only
+** - no service configuration, distributed or otherwise
+** - no type hierarchy lookup, injected types must be explicitly defined (or Mixin + Impl)
+** - only 1 (global) scope
+@Js class MiniIoc {
+
+ private Str:Obj? config
+ private Type:Obj? cache
+
+ new make([Type:Obj]? cache := null, [Str:Obj?]? config := null) {
+ this.cache = cache?.rw ?: Type:Obj?[:]
+ this.config = config?.rw ?: Str:Obj?[:]
+ }
+
+ ** Gets or builds (and caches) the given type.
+ @Operator
+ Obj get(Type type) {
+ getOrBuildVal(type)
+ }
+
+ ** Creates a new instance of the given type.
+ Obj build(Type type, Obj?[]? ctorArgs := null) {
+ plan := Field.makeSetFunc(createPlan(type))
+ args := (ctorArgs ?: Obj?[,]).add(plan)
+ return type.make(args)
+ }
+
+ private Field:Obj? createPlan(Type type) {
+ plan := Field:Obj?[:]
+ type.fields.each |field| {
+ if (field.isStatic) return
+ if (field.hasFacet(Config#))
+ plan[field] = getConf(field.name, field.type)
+ if (field.hasFacet(Inject#))
+ plan[field] = getOrBuildVal(field.type)
+ }
+ return plan
+ }
+
+ private Obj? getConf(Str name, Type type) {
+ key := name.contains(".") ? name : config.keys.find |key| { key.split('.').last == name }
+ val := key == null ? null : config[key]
+
+ if (val == null) return null
+ switch (type) {
+ case Bool# : return val.toStr.toBool
+ case Duration# : return Duration(val.toStr)
+ case Int# : return val.toStr.toInt
+ case Str# : return val.toStr
+ case Uri# : return val.toStr.toUri
+ }
+ return val
+ }
+
+ private Obj? getOrBuildVal(Type type) {
+ switch (type) {
+ case MiniIoc# : return this
+ case Log# : return typeof.pod.log
+ default : return cache.getOrAdd(type) { autobuild(type) }
+ }
+ throw UnsupportedErr("Could not inject: $type.qname")
+ }
+
+ private Obj autobuild(Type type) {
+ try {
+ if (type.isMixin) type = Type.find(type.qname + "Impl", true)
+ ctors := type.methods.findAll { it.isCtor }.sortr |p1, p2| { p1.params.size <=> p2.params.size }
+ ctor := ctors.find { it.hasFacet(Inject#) } ?: ctors.first
+ if (ctor == null)
+ throw Err("No IoC ctor found on ${type.qname}")
+
+ args := ctor.params.map {
+ it.type.fits(Func#) ? Field.makeSetFunc(createPlan(type)) : getOrBuildVal(it.type)
+ }
+
+ return ctor.callList(args)
+
+ } catch (Err err)
+ throw Err("Could not autobuild $type", err)
+ }
+}
+
+@Js internal facet class Inject {}
+@Js internal facet class Config {}
diff --git a/fan/util/JsUtil.fan b/fan/util/JsUtil.fan
new file mode 100644
index 0000000..f6612d7
--- /dev/null
+++ b/fan/util/JsUtil.fan
@@ -0,0 +1,46 @@
+using dom::Elem
+using dom::CssDim
+
+@Js const class JsUtil {
+
+ ** Returns a new DateTime in the user's time zone.
+ ** Note the date is correct, but the TimeZone is not - we just adjust the time.
+ static DateTime toLocalTs(DateTime dateTime) {
+ dateTime.toUtc.minus(utcOffset)
+ }
+
+ static Duration utcOffset() {
+ 1min * (Env.cur.runtime == "js" ? getTimezoneOffset : (TimeZone.cur.offset(Date.today.year) + TimeZone.cur.dstOffset(Date.today.year)).toMin)
+ }
+
+ ** Returns the offset in minutes.
+ private native static Int getTimezoneOffset()
+
+ native static Float getScrollbarWidth()
+
+ static Void addScrollbarWidth(Elem elem, Str propName) {
+ className := "data-addScrollbarWidth-${propName}"
+
+ // don't double up the padding when called multiple times
+ if (!elem.style.hasClass(className)) {
+ scrollbarWidth := JsUtil.getScrollbarWidth
+ parseFloat := |Str prop->Float| { CssDim(prop.trimToNull ?: "0px").val.toFloat }
+ oldProp := parseFloat(elem.style.get(propName))
+ elem.setProp(className, oldProp)
+ elem.style.set(propName, "${oldProp + scrollbarWidth}px")
+ elem.style.addClass(className)
+ }
+ }
+
+ static Void removeScrollbarWidth(Elem elem, Str propName) {
+ className := "data-addScrollbarWidth-${propName}"
+ if (elem.style.hasClass(className)) {
+ oldProp := elem.prop(className)
+ elem.style.set(propName, "${oldProp}px")
+ elem.setProp(className, null)
+ elem.style.removeClass(className)
+ }
+ }
+
+ native static Str copyToClipboard(Str text)
+}
diff --git a/js/JsUtilPeer.js b/js/JsUtilPeer.js
new file mode 100644
index 0000000..8df6714
--- /dev/null
+++ b/js/JsUtilPeer.js
@@ -0,0 +1,53 @@
+
+fan.afAppKit.JsUtilPeer = fan.sys.Obj.$extend(fan.sys.Obj);
+
+fan.afAppKit.JsUtilPeer.prototype.$ctor = function(self) {};
+
+// this method does work, it's just that the offset solution seems easier!
+//fan.afAppKit.JsUtil.toLocalDateTime = function(date) {
+// var utc = date.toUtc();
+// var local = new Date(Date.UTC(utc.year(), utc.month().ordinal(), utc.day(), utc.hour(), utc.min(), utc.sec()));
+// return fan.sys.DateTime.make(local.getFullYear(), fan.sys.Month.m_vals.get(local.getMonth()), local.getDate(), local.getHours(), local.getMinutes(), local.getSeconds());
+//}
+
+fan.afAppKit.JsUtilPeer.getTimezoneOffset = function() {
+ return new Date().getTimezoneOffset();
+};
+
+fan.afAppKit.JsUtilPeer.getScrollbarWidth = function() {
+ var scrollDiv = document.createElement("div");
+ scrollDiv.className = "modal-scrollbar-measure";
+ document.body.appendChild(scrollDiv);
+ var scrollbarWidth = scrollDiv.getBoundingClientRect().width - scrollDiv.clientWidth;
+ document.body.removeChild(scrollDiv);
+ return scrollbarWidth;
+};
+
+// https://stackoverflow.com/questions/400212/how-do-i-copy-to-the-clipboard-in-javascript
+fan.afAppKit.JsUtilPeer.copyToClipboard = function(text) {
+
+ // try to copy the easy way first
+ if (navigator.clipboard) {
+ // don't bother reporting errors
+ navigator.clipboard.writeText(text);
+ return text;
+ }
+
+ // the fallback copy method
+ var textArea = document.createElement("textarea");
+ textArea.value = text;
+
+ // svoid scrolling to bottom
+ textArea.style.top = "0";
+ textArea.style.left = "0";
+ textArea.style.position = "fixed";
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+
+ try { document.execCommand('copy'); }
+ catch (err) { /* meh */ }
+
+ document.body.removeChild(textArea);
+ return text;
+};