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; +};