purview-0.2.0.0: src/Wrapper.hs
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE OverloadedStrings #-}
module Wrapper where
import Text.RawString.QQ (r)
{-
Loosely, for each type of event, check if it has a "clickLocation" "blurLocation" etc
Each event would have its own location and if it doesn't, send nothing
I think that actually does it?
Might also want to move to using "clickId" etc, if you want to.
-}
eventHandlerFn :: String
eventHandlerFn = [r|
function eventHandler(event) {
event.stopPropagation();
const type = event.type;
// ie "click-location" or "blur-location"
const locationCheck = `${type}-location`;
const possibleLocation = event.target.getAttribute(locationCheck);
if (possibleLocation) {
const childLocation = JSON.parse(possibleLocation)
if (type === "submit") {
event.preventDefault();
var form = new FormData(event.target);
var entries = JSON.stringify(Object.fromEntries(form.entries()));
var location = JSON.parse(event.currentTarget.getAttribute("handler"));
window.ws.send(JSON.stringify({
"event": "submit",
"value": entries,
"childLocation": childLocation,
"location": location
}));
} else {
var value = event.target.value;
var location = JSON.parse(event.currentTarget.getAttribute("handler"))
var wrappedValue = typeof value === 'string' ? value : `${value}`;
window.ws.send(JSON.stringify({
"event": type,
"value": wrappedValue,
"childLocation": childLocation,
"location": location
}));
}
}
}
|]
bindEventsFn :: String
bindEventsFn = [r|
function bindEvents() {
document.querySelectorAll("[handler]").forEach(item => {
if (!item.getAttribute("bound")) {
events.map(event => {
item.addEventListener(event, eventHandler)
})
item.setAttribute("bound", "true")
}
})
}
|]
eventHandling :: [String] -> String
eventHandling eventsList =
let eventsToWatch = "const events = " <> show eventsList
in eventsToWatch <> "\n" <> eventHandlerFn <> "\n" <> bindEventsFn
{-
This is so that event locations are added as events bubble past,
reflecting how you'd expect them to work.
Without this, for example, click events on a div beneath the one
with the click-location would be ignored.
-}
eventBubblingHandling :: String
eventBubblingHandling = [r|
function bindLocationEnrichment() {
document.querySelectorAll("[bubbling-bound]").forEach(item => {
const alreadyBound = item.getAttribute('bubbling-bound')
if (!alreadyBound) {
events.map(event => {
const eventLocation = item.getAttribute(`${event}-location`)
if (eventLocation) {
item.addEventListener(event, eventFromDOM => {
eventFromDOM.target.setAttribute(`${event}-location`, eventLocation)
})
}
})
item.setAttribute('bubbling-bound', true)
}
})
}
|]
websocketScript :: Bool -> String
websocketScript secure =
let connectionPath = if secure
then "var ws = new WebSocket('wss://' + window.location.host + window.location.pathname);"
else "var ws = new WebSocket('ws://' + window.location.host + window.location.pathname);"
in
connectionPath <> [r|
var timeoutTime = -50;
function connect() {
timeoutTime += 50;
// TODO: adding the current path is kind of a hack
ws.onopen = () => {
timeoutTime = 0;
};
ws.onmessage = evt => {
var m = evt.data;
var event = JSON.parse(evt.data);
if (event.event === "setHtml") {
// cool enough for now
event.message.map(command => setHtml(command));
bindEvents();
bindLocationEnrichment();
} else if (event.event === "callJS") {
const [fnToCall, withValue] = event.message;
window[fnToCall](withValue);
} else if (event.event === "setCSS") {
var sheet = window.document.styleSheets[0];
event.message.map(cssRule => {
const [name, css] = cssRule;
sheet.insertRule('.' + name + '{ ' + css + ' }', sheet.cssRules.length);
})
}
};
ws.onclose = function() {
setTimeout(function() {
console.debug("Attempting to reconnect");
connect();
}, timeoutTime);
};
window.onbeforeunload = evt => {
ws.close();
};
window.ws = ws;
}
connect();
function getNode(location) {
let currentNode = document.body;
while (location.length > 0) {
const index = location.pop();
currentNode = currentNode.childNodes[index];
}
return currentNode;
}
function setHtml(message) {
const command = message.message;
const [location, newHtml] = message.contents;
const targetNode = getNode(location);
// hacky fix for https://github.com/purview-framework/purview/issues/123
if (targetNode.tagName === "BODY") {
targetNode.innerHTML = newHtml;
} else {
targetNode.outerHTML = newHtml;
}
}
|]
sendEventHelper :: String
sendEventHelper = [r|
const sendEvent = (receiverName, value) => {
const targets = document.querySelectorAll("[receiver-name=" + receiverName + "]")
if (targets.length > 1) {
console.error("too many")
} else if (targets.length == 0) {
console.error("none found")
} else {
var target = targets[0]
var location = JSON.parse(target.getAttribute("parent-handler"))
var childLocation = JSON.parse(target.getAttribute("handler"))
window.ws.send(JSON.stringify({
"event": "submit",
"value": value,
"childLocation": childLocation,
"location": location
}));
}
}
|]
prepareCss :: [(String, String)] -> String
prepareCss = concatMap (\(hash, css) -> "." <> hash <> " {" <> css <> "}\n")
wrapHtml
:: [(String, String)]
-> String
-> [String]
-> String
-> Bool
-> String
-> String
wrapHtml css htmlHead eventsToListenTo javascript secure body =
"<!DOCTYPE html>"
<> "<html>"
<> "<head>"
<> htmlHead
<> "<script>"
<> websocketScript secure
<> eventHandling eventsToListenTo
<> eventBubblingHandling
<> "</script>"
<> "<script>"
<> sendEventHelper
<> "</script>"
<> "<script>"
<> javascript
<> "</script>"
<> "<style>"
<> prepareCss css
<> "</style>"
<> "</head>"
<> "<body>"
<> body
<> "<script>bindEvents(); bindLocationEnrichment();</script>"
<> "</body>"
<> "</html>"