mirror of
https://github.com/Theodor-Springmann-Stiftung/kgpz_web.git
synced 2025-10-28 16:45:32 +00:00
XSLT examlpe
This commit is contained in:
@@ -1,8 +1,17 @@
|
||||
package functions
|
||||
|
||||
import "html/template"
|
||||
|
||||
func FirstLetter(s string) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(s[:1])
|
||||
}
|
||||
|
||||
func Safe(s string) template.HTML {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
return template.HTML(s)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ func (e *Engine) Funcs(app *app.KGPZ) error {
|
||||
e.AddFunc("FirstLetter", functions.FirstLetter)
|
||||
e.AddFunc("Upper", strings.ToUpper)
|
||||
e.AddFunc("Lower", strings.ToLower)
|
||||
e.AddFunc("Safe", functions.Safe)
|
||||
|
||||
// App specific
|
||||
e.AddFunc("GetAgent", app.Library.Agents.Item)
|
||||
|
||||
97
views/assets/js/class-tools.js
Normal file
97
views/assets/js/class-tools.js
Normal file
@@ -0,0 +1,97 @@
|
||||
(function() {
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.split(/\s+/)
|
||||
}
|
||||
|
||||
function parseClassOperation(trimmedValue) {
|
||||
var split = splitOnWhitespace(trimmedValue)
|
||||
if (split.length > 1) {
|
||||
var operation = split[0]
|
||||
var classDef = split[1].trim()
|
||||
var cssClass
|
||||
var delay
|
||||
if (classDef.indexOf(':') > 0) {
|
||||
var splitCssClass = classDef.split(':')
|
||||
cssClass = splitCssClass[0]
|
||||
delay = htmx.parseInterval(splitCssClass[1])
|
||||
} else {
|
||||
cssClass = classDef
|
||||
delay = 100
|
||||
}
|
||||
return {
|
||||
operation,
|
||||
cssClass,
|
||||
delay
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function performOperation(elt, classOperation, classList, currentRunTime) {
|
||||
setTimeout(function() {
|
||||
elt.classList[classOperation.operation].call(elt.classList, classOperation.cssClass)
|
||||
}, currentRunTime)
|
||||
}
|
||||
|
||||
function toggleOperation(elt, classOperation, classList, currentRunTime) {
|
||||
setTimeout(function() {
|
||||
setInterval(function() {
|
||||
elt.classList[classOperation.operation].call(elt.classList, classOperation.cssClass)
|
||||
}, classOperation.delay)
|
||||
}, currentRunTime)
|
||||
}
|
||||
|
||||
function processClassList(elt, classList) {
|
||||
var runs = classList.split('&')
|
||||
for (var i = 0; i < runs.length; i++) {
|
||||
var run = runs[i]
|
||||
var currentRunTime = 0
|
||||
var classOperations = run.split(',')
|
||||
for (var j = 0; j < classOperations.length; j++) {
|
||||
var value = classOperations[j]
|
||||
var trimmedValue = value.trim()
|
||||
var classOperation = parseClassOperation(trimmedValue)
|
||||
if (classOperation) {
|
||||
if (classOperation.operation === 'toggle') {
|
||||
toggleOperation(elt, classOperation, classList, currentRunTime)
|
||||
currentRunTime = currentRunTime + classOperation.delay
|
||||
} else {
|
||||
currentRunTime = currentRunTime + classOperation.delay
|
||||
performOperation(elt, classOperation, classList, currentRunTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeProcessClasses(elt) {
|
||||
if (elt.getAttribute) {
|
||||
var classList = elt.getAttribute('classes') || elt.getAttribute('data-classes')
|
||||
if (classList) {
|
||||
processClassList(elt, classList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension('class-tools', {
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:afterProcessNode') {
|
||||
var elt = evt.detail.elt
|
||||
maybeProcessClasses(elt)
|
||||
var classList = elt.getAttribute("apply-parent-classes") || elt.getAttribute("data-apply-parent-classes");
|
||||
if (classList) {
|
||||
var parent = elt.parentElement;
|
||||
parent.removeChild(elt);
|
||||
parent.setAttribute("classes", classList);
|
||||
maybeProcessClasses(parent);
|
||||
} else if (elt.querySelectorAll) {
|
||||
var children = elt.querySelectorAll('[classes], [data-classes]')
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
maybeProcessClasses(children[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
96
views/assets/js/client-side-templates.js
Normal file
96
views/assets/js/client-side-templates.js
Normal file
@@ -0,0 +1,96 @@
|
||||
htmx.defineExtension('client-side-templates', {
|
||||
transformResponse: function(text, xhr, elt) {
|
||||
var mustacheTemplate = htmx.closest(elt, '[mustache-template]')
|
||||
if (mustacheTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateId = mustacheTemplate.getAttribute('mustache-template')
|
||||
var template = htmx.find('#' + templateId)
|
||||
if (template) {
|
||||
return Mustache.render(template.innerHTML, data)
|
||||
} else {
|
||||
throw new Error('Unknown mustache template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var mustacheArrayTemplate = htmx.closest(elt, '[mustache-array-template]')
|
||||
if (mustacheArrayTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateId = mustacheArrayTemplate.getAttribute('mustache-array-template')
|
||||
var template = htmx.find('#' + templateId)
|
||||
if (template) {
|
||||
return Mustache.render(template.innerHTML, { data })
|
||||
} else {
|
||||
throw new Error('Unknown mustache template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var handlebarsTemplate = htmx.closest(elt, '[handlebars-template]')
|
||||
if (handlebarsTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateId = handlebarsTemplate.getAttribute('handlebars-template')
|
||||
var templateElement = htmx.find('#' + templateId).innerHTML
|
||||
var renderTemplate = Handlebars.compile(templateElement)
|
||||
if (renderTemplate) {
|
||||
return renderTemplate(data)
|
||||
} else {
|
||||
throw new Error('Unknown handlebars template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var handlebarsArrayTemplate = htmx.closest(elt, '[handlebars-array-template]')
|
||||
if (handlebarsArrayTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateId = handlebarsArrayTemplate.getAttribute('handlebars-array-template')
|
||||
var templateElement = htmx.find('#' + templateId).innerHTML
|
||||
var renderTemplate = Handlebars.compile(templateElement)
|
||||
if (renderTemplate) {
|
||||
return renderTemplate(data)
|
||||
} else {
|
||||
throw new Error('Unknown handlebars template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var nunjucksTemplate = htmx.closest(elt, '[nunjucks-template]')
|
||||
if (nunjucksTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateName = nunjucksTemplate.getAttribute('nunjucks-template')
|
||||
var template = htmx.find('#' + templateName)
|
||||
if (template) {
|
||||
return nunjucks.renderString(template.innerHTML, data)
|
||||
} else {
|
||||
return nunjucks.render(templateName, data)
|
||||
}
|
||||
}
|
||||
|
||||
var xsltTemplate = htmx.closest(elt, '[xslt-template]')
|
||||
if (xsltTemplate) {
|
||||
var templateId = xsltTemplate.getAttribute('xslt-template')
|
||||
var template = htmx.find('#' + templateId)
|
||||
if (template) {
|
||||
var content = template.innerHTML
|
||||
? new DOMParser().parseFromString(template.innerHTML, 'application/xml')
|
||||
: template.contentDocument
|
||||
var processor = new XSLTProcessor()
|
||||
processor.importStylesheet(content)
|
||||
var data = new DOMParser().parseFromString(text, 'application/xml')
|
||||
var frag = processor.transformToFragment(data, document)
|
||||
return new XMLSerializer().serializeToString(frag)
|
||||
} else {
|
||||
throw new Error('Unknown XSLT template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var nunjucksArrayTemplate = htmx.closest(elt, '[nunjucks-array-template]')
|
||||
if (nunjucksArrayTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateName = nunjucksArrayTemplate.getAttribute('nunjucks-array-template')
|
||||
var template = htmx.find('#' + templateName)
|
||||
if (template) {
|
||||
return nunjucks.renderString(template.innerHTML, { data })
|
||||
} else {
|
||||
return nunjucks.render(templateName, { data })
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
})
|
||||
144
views/assets/js/head-support.js
Normal file
144
views/assets/js/head-support.js
Normal file
@@ -0,0 +1,144 @@
|
||||
//==========================================================
|
||||
// head-support.js
|
||||
//
|
||||
// An extension to add head tag merging.
|
||||
//==========================================================
|
||||
(function(){
|
||||
|
||||
var api = null;
|
||||
|
||||
function log() {
|
||||
//console.log(arguments);
|
||||
}
|
||||
|
||||
function mergeHead(newContent, defaultMergeStrategy) {
|
||||
|
||||
if (newContent && newContent.indexOf('<head') > -1) {
|
||||
const htmlDoc = document.createElement("html");
|
||||
// remove svgs to avoid conflicts
|
||||
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
||||
// extract head tag
|
||||
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
|
||||
|
||||
// if the head tag exists...
|
||||
if (headTag) {
|
||||
|
||||
var added = []
|
||||
var removed = []
|
||||
var preserved = []
|
||||
var nodesToAppend = []
|
||||
|
||||
htmlDoc.innerHTML = headTag;
|
||||
var newHeadTag = htmlDoc.querySelector("head");
|
||||
var currentHead = document.head;
|
||||
|
||||
if (newHeadTag == null) {
|
||||
return;
|
||||
} else {
|
||||
// put all new head elements into a Map, by their outerHTML
|
||||
var srcToNewHeadNodes = new Map();
|
||||
for (const newHeadChild of newHeadTag.children) {
|
||||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// determine merge strategy
|
||||
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
|
||||
|
||||
// get the current head
|
||||
for (const currentHeadElt of currentHead.children) {
|
||||
|
||||
// If the current head element is in the map
|
||||
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
||||
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
|
||||
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
|
||||
if (inNewContent || isPreserved) {
|
||||
if (isReAppended) {
|
||||
// remove the current version and let the new version replace it and re-execute
|
||||
removed.push(currentHeadElt);
|
||||
} else {
|
||||
// this element already exists and should not be re-appended, so remove it from
|
||||
// the new content map, preserving it in the DOM
|
||||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
||||
preserved.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
if (mergeStrategy === "append") {
|
||||
// we are appending and this existing element is not new content
|
||||
// so if and only if it is marked for re-append do we do anything
|
||||
if (isReAppended) {
|
||||
removed.push(currentHeadElt);
|
||||
nodesToAppend.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
// if this is a merge, we remove this content since it is not in the new head
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
|
||||
removed.push(currentHeadElt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push the tremaining new head elements in the Map into the
|
||||
// nodes to append to the head tag
|
||||
nodesToAppend.push(...srcToNewHeadNodes.values());
|
||||
log("to append: ", nodesToAppend);
|
||||
|
||||
for (const newNode of nodesToAppend) {
|
||||
log("adding: ", newNode);
|
||||
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
|
||||
log(newElt);
|
||||
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
|
||||
currentHead.appendChild(newElt);
|
||||
added.push(newElt);
|
||||
}
|
||||
}
|
||||
|
||||
// remove all removed elements, after we have appended the new elements to avoid
|
||||
// additional network requests for things like style sheets
|
||||
for (const removedElement of removed) {
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
|
||||
currentHead.removeChild(removedElement);
|
||||
}
|
||||
}
|
||||
|
||||
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension("head-support", {
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef;
|
||||
|
||||
htmx.on('htmx:afterSwap', function(evt){
|
||||
let xhr = evt.detail.xhr;
|
||||
if (xhr) {
|
||||
var serverResponse = xhr.response;
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyRestore', function(evt){
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
if (evt.detail.cacheMiss) {
|
||||
mergeHead(evt.detail.serverResponse, "merge");
|
||||
} else {
|
||||
mergeHead(evt.detail.item.head, "merge");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyItemCreated', function(evt){
|
||||
var historyItem = evt.detail.item;
|
||||
historyItem.head = document.head.outerHTML;
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
})()
|
||||
23
views/assets/js/include-vals.js
Normal file
23
views/assets/js/include-vals.js
Normal file
@@ -0,0 +1,23 @@
|
||||
(function() {
|
||||
function mergeObjects(obj1, obj2) {
|
||||
for (var key in obj2) {
|
||||
if (obj2.hasOwnProperty(key)) {
|
||||
obj1[key] = obj2[key]
|
||||
}
|
||||
}
|
||||
return obj1
|
||||
}
|
||||
|
||||
htmx.defineExtension('include-vals', {
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:configRequest') {
|
||||
var includeValsElt = htmx.closest(evt.detail.elt, '[include-vals],[data-include-vals]')
|
||||
if (includeValsElt) {
|
||||
var includeVals = includeValsElt.getAttribute('include-vals') || includeValsElt.getAttribute('data-include-vals')
|
||||
var valuesToInclude = eval('({' + includeVals + '})')
|
||||
mergeObjects(evt.detail.parameters, valuesToInclude)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
184
views/assets/js/loading-states.js
Normal file
184
views/assets/js/loading-states.js
Normal file
@@ -0,0 +1,184 @@
|
||||
;(function() {
|
||||
const loadingStatesUndoQueue = []
|
||||
|
||||
function loadingStateContainer(target) {
|
||||
return htmx.closest(target, '[data-loading-states]') || document.body
|
||||
}
|
||||
|
||||
function mayProcessUndoCallback(target, callback) {
|
||||
if (document.body.contains(target)) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
function mayProcessLoadingStateByPath(elt, requestPath) {
|
||||
const pathElt = htmx.closest(elt, '[data-loading-path]')
|
||||
if (!pathElt) {
|
||||
return true
|
||||
}
|
||||
|
||||
return pathElt.getAttribute('data-loading-path') === requestPath
|
||||
}
|
||||
|
||||
function queueLoadingState(sourceElt, targetElt, doCallback, undoCallback) {
|
||||
const delayElt = htmx.closest(sourceElt, '[data-loading-delay]')
|
||||
if (delayElt) {
|
||||
const delayInMilliseconds =
|
||||
delayElt.getAttribute('data-loading-delay') || 200
|
||||
const timeout = setTimeout(function() {
|
||||
doCallback()
|
||||
|
||||
loadingStatesUndoQueue.push(function() {
|
||||
mayProcessUndoCallback(targetElt, undoCallback)
|
||||
})
|
||||
}, delayInMilliseconds)
|
||||
|
||||
loadingStatesUndoQueue.push(function() {
|
||||
mayProcessUndoCallback(targetElt, function() { clearTimeout(timeout) })
|
||||
})
|
||||
} else {
|
||||
doCallback()
|
||||
loadingStatesUndoQueue.push(function() {
|
||||
mayProcessUndoCallback(targetElt, undoCallback)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getLoadingStateElts(loadingScope, type, path) {
|
||||
return Array.from(htmx.findAll(loadingScope, '[' + type + ']')).filter(
|
||||
function(elt) { return mayProcessLoadingStateByPath(elt, path) }
|
||||
)
|
||||
}
|
||||
|
||||
function getLoadingTarget(elt) {
|
||||
if (elt.getAttribute('data-loading-target')) {
|
||||
return Array.from(
|
||||
htmx.findAll(elt.getAttribute('data-loading-target'))
|
||||
)
|
||||
}
|
||||
return [elt]
|
||||
}
|
||||
|
||||
htmx.defineExtension('loading-states', {
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:beforeRequest') {
|
||||
const container = loadingStateContainer(evt.target)
|
||||
|
||||
const loadingStateTypes = [
|
||||
'data-loading',
|
||||
'data-loading-class',
|
||||
'data-loading-class-remove',
|
||||
'data-loading-disable',
|
||||
'data-loading-aria-busy'
|
||||
]
|
||||
|
||||
const loadingStateEltsByType = {}
|
||||
|
||||
loadingStateTypes.forEach(function(type) {
|
||||
loadingStateEltsByType[type] = getLoadingStateElts(
|
||||
container,
|
||||
type,
|
||||
evt.detail.pathInfo.requestPath
|
||||
)
|
||||
})
|
||||
|
||||
loadingStateEltsByType['data-loading'].forEach(function(sourceElt) {
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() {
|
||||
targetElt.style.display =
|
||||
sourceElt.getAttribute('data-loading') ||
|
||||
'inline-block'
|
||||
},
|
||||
function() { targetElt.style.display = 'none' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
loadingStateEltsByType['data-loading-class'].forEach(
|
||||
function(sourceElt) {
|
||||
const classNames = sourceElt
|
||||
.getAttribute('data-loading-class')
|
||||
.split(' ')
|
||||
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() {
|
||||
classNames.forEach(function(className) {
|
||||
targetElt.classList.add(className)
|
||||
})
|
||||
},
|
||||
function() {
|
||||
classNames.forEach(function(className) {
|
||||
targetElt.classList.remove(className)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
loadingStateEltsByType['data-loading-class-remove'].forEach(
|
||||
function(sourceElt) {
|
||||
const classNames = sourceElt
|
||||
.getAttribute('data-loading-class-remove')
|
||||
.split(' ')
|
||||
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() {
|
||||
classNames.forEach(function(className) {
|
||||
targetElt.classList.remove(className)
|
||||
})
|
||||
},
|
||||
function() {
|
||||
classNames.forEach(function(className) {
|
||||
targetElt.classList.add(className)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
loadingStateEltsByType['data-loading-disable'].forEach(
|
||||
function(sourceElt) {
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() { targetElt.disabled = true },
|
||||
function() { targetElt.disabled = false }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
loadingStateEltsByType['data-loading-aria-busy'].forEach(
|
||||
function(sourceElt) {
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() { targetElt.setAttribute('aria-busy', 'true') },
|
||||
function() { targetElt.removeAttribute('aria-busy') }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'htmx:beforeOnLoad') {
|
||||
while (loadingStatesUndoQueue.length > 0) {
|
||||
loadingStatesUndoQueue.shift()()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
44
views/assets/js/multi-swap.js
Normal file
44
views/assets/js/multi-swap.js
Normal file
@@ -0,0 +1,44 @@
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('multi-swap', {
|
||||
init: function(apiRef) {
|
||||
api = apiRef
|
||||
},
|
||||
isInlineSwap: function(swapStyle) {
|
||||
return swapStyle.indexOf('multi:') === 0
|
||||
},
|
||||
handleSwap: function(swapStyle, target, fragment, settleInfo) {
|
||||
if (swapStyle.indexOf('multi:') === 0) {
|
||||
var selectorToSwapStyle = {}
|
||||
var elements = swapStyle.replace(/^multi\s*:\s*/, '').split(/\s*,\s*/)
|
||||
|
||||
elements.forEach(function(element) {
|
||||
var split = element.split(/\s*:\s*/)
|
||||
var elementSelector = split[0]
|
||||
var elementSwapStyle = typeof (split[1]) !== 'undefined' ? split[1] : 'innerHTML'
|
||||
|
||||
if (elementSelector.charAt(0) !== '#') {
|
||||
console.error("HTMX multi-swap: unsupported selector '" + elementSelector + "'. Only ID selectors starting with '#' are supported.")
|
||||
return
|
||||
}
|
||||
|
||||
selectorToSwapStyle[elementSelector] = elementSwapStyle
|
||||
})
|
||||
|
||||
for (var selector in selectorToSwapStyle) {
|
||||
var swapStyle = selectorToSwapStyle[selector]
|
||||
var elementToSwap = fragment.querySelector(selector)
|
||||
if (elementToSwap) {
|
||||
api.oobSwap(swapStyle, elementToSwap, settleInfo)
|
||||
} else {
|
||||
console.warn("HTMX multi-swap: selector '" + selector + "' not found in source content.")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
11
views/assets/js/path-params.js
Normal file
11
views/assets/js/path-params.js
Normal file
@@ -0,0 +1,11 @@
|
||||
htmx.defineExtension('path-params', {
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:configRequest') {
|
||||
evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) {
|
||||
var val = evt.detail.parameters[param]
|
||||
delete evt.detail.parameters[param]
|
||||
return val === undefined ? '{' + param + '}' : encodeURIComponent(val)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
388
views/assets/js/preload.js
Normal file
388
views/assets/js/preload.js
Normal file
@@ -0,0 +1,388 @@
|
||||
(function() {
|
||||
/**
|
||||
* This adds the "preload" extension to htmx. The extension will
|
||||
* preload the targets of elements with "preload" attribute if:
|
||||
* - they also have `href`, `hx-get` or `data-hx-get` attributes
|
||||
* - they are radio buttons, checkboxes, select elements and submit
|
||||
* buttons of forms with `method="get"` or `hx-get` attributes
|
||||
* The extension relies on browser cache and for it to work
|
||||
* server response must include `Cache-Control` header
|
||||
* e.g. `Cache-Control: private, max-age=60`.
|
||||
* For more details @see https://htmx.org/extensions/preload/
|
||||
*/
|
||||
|
||||
htmx.defineExtension('preload', {
|
||||
onEvent: function(name, event) {
|
||||
// Process preload attributes on `htmx:afterProcessNode`
|
||||
if (name === 'htmx:afterProcessNode') {
|
||||
// Initialize all nodes with `preload` attribute
|
||||
const parent = event.target || event.detail.elt;
|
||||
const preloadNodes = [
|
||||
...parent.hasAttribute("preload") ? [parent] : [],
|
||||
...parent.querySelectorAll("[preload]")]
|
||||
preloadNodes.forEach(function(node) {
|
||||
// Initialize the node with the `preload` attribute
|
||||
init(node)
|
||||
|
||||
// Initialize all child elements which has
|
||||
// `href`, `hx-get` or `data-hx-get` attributes
|
||||
node.querySelectorAll('[href],[hx-get],[data-hx-get]').forEach(init)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Intercept HTMX preload requests on `htmx:beforeRequest` and
|
||||
// send them as XHR requests instead to avoid side-effects,
|
||||
// such as showing loading indicators while preloading data.
|
||||
if (name === 'htmx:beforeRequest') {
|
||||
const requestHeaders = event.detail.requestConfig.headers
|
||||
if (!("HX-Preloaded" in requestHeaders
|
||||
&& requestHeaders["HX-Preloaded"] === "true")) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
// Reuse XHR created by HTMX with replaced callbacks
|
||||
const xhr = event.detail.xhr
|
||||
xhr.onload = function() {
|
||||
processResponse(event.detail.elt, xhr.responseText)
|
||||
}
|
||||
xhr.onerror = null
|
||||
xhr.onabort = null
|
||||
xhr.ontimeout = null
|
||||
xhr.send()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Initialize `node`, set up event handlers based on own or inherited
|
||||
* `preload` attributes and set `node.preloadState` to `READY`.
|
||||
*
|
||||
* `node.preloadState` can have these values:
|
||||
* - `READY` - event handlers have been set up and node is ready to preload
|
||||
* - `TIMEOUT` - a triggering event has been fired, but `node` is not
|
||||
* yet being loaded because some time need to pass first e.g. user
|
||||
* has to keep hovering over an element for 100ms for preload to start
|
||||
* - `LOADING` means that `node` is in the process of being preloaded
|
||||
* - `DONE` means that the preloading process is complete and `node`
|
||||
* doesn't need a repeated preload (indicated by preload="always")
|
||||
* @param {Node} node
|
||||
*/
|
||||
function init(node) {
|
||||
// Guarantee that each node is initialized only once
|
||||
if (node.preloadState !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidNodeForPreloading(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize form element preloading
|
||||
if (node instanceof HTMLFormElement) {
|
||||
const form = node
|
||||
// Only initialize forms with `method="get"` or `hx-get` attributes
|
||||
if (!((form.hasAttribute('method') && form.method === 'get')
|
||||
|| form.hasAttribute('hx-get') || form.hasAttribute('hx-data-get'))) {
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < form.elements.length; i++) {
|
||||
const element = form.elements.item(i);
|
||||
init(element);
|
||||
element.labels.forEach(init);
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Process node configuration from preload attribute
|
||||
let preloadAttr = getClosestAttribute(node, 'preload');
|
||||
node.preloadAlways = preloadAttr && preloadAttr.includes('always');
|
||||
if (node.preloadAlways) {
|
||||
preloadAttr = preloadAttr.replace('always', '').trim();
|
||||
}
|
||||
let triggerEventName = preloadAttr || 'mousedown';
|
||||
|
||||
// Set up event handlers listening for triggering events
|
||||
const needsTimeout = triggerEventName === 'mouseover'
|
||||
node.addEventListener(triggerEventName, getEventHandler(node, needsTimeout))
|
||||
|
||||
// Add `touchstart` listener for touchscreen support
|
||||
// if `mousedown` or `mouseover` is used
|
||||
if (triggerEventName === 'mousedown' || triggerEventName === 'mouseover') {
|
||||
node.addEventListener('touchstart', getEventHandler(node))
|
||||
}
|
||||
|
||||
// If `mouseover` is used, set up `mouseout` listener,
|
||||
// which will abort preloading if user moves mouse outside
|
||||
// the element in less than 100ms after hovering over it
|
||||
if (triggerEventName === 'mouseover') {
|
||||
node.addEventListener('mouseout', function(evt) {
|
||||
if ((evt.target === node) && (node.preloadState === 'TIMEOUT')) {
|
||||
node.preloadState = 'READY'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mark the node as ready to be preloaded
|
||||
node.preloadState = 'READY'
|
||||
|
||||
// This event can be used to load content immediately
|
||||
htmx.trigger(node, 'preload:init')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return event handler which can be called by event listener to start
|
||||
* the preloading process of `node` with or without a timeout
|
||||
* @param {Node} node
|
||||
* @param {boolean=} needsTimeout
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
function getEventHandler(node, needsTimeout = false) {
|
||||
return function() {
|
||||
// Do not preload uninitialized nodes, nodes which are in process
|
||||
// of being preloaded or have been preloaded and don't need repeat
|
||||
if (node.preloadState !== 'READY') {
|
||||
return
|
||||
}
|
||||
|
||||
if (needsTimeout) {
|
||||
node.preloadState = 'TIMEOUT'
|
||||
const timeoutMs = 100
|
||||
window.setTimeout(function() {
|
||||
if (node.preloadState === 'TIMEOUT') {
|
||||
node.preloadState = 'READY'
|
||||
load(node)
|
||||
}
|
||||
}, timeoutMs)
|
||||
return
|
||||
}
|
||||
|
||||
load(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload the target of node, which can be:
|
||||
* - hx-get or data-hx-get attribute
|
||||
* - href or form action attribute
|
||||
* @param {Node} node
|
||||
*/
|
||||
function load(node) {
|
||||
// Do not preload uninitialized nodes, nodes which are in process
|
||||
// of being preloaded or have been preloaded and don't need repeat
|
||||
if (node.preloadState !== 'READY') {
|
||||
return
|
||||
}
|
||||
node.preloadState = 'LOADING'
|
||||
|
||||
// Load nodes with `hx-get` or `data-hx-get` attribute
|
||||
// Forms don't reach this because only their elements are initialized
|
||||
const hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get')
|
||||
if (hxGet) {
|
||||
sendHxGetRequest(hxGet, node);
|
||||
return
|
||||
}
|
||||
|
||||
// Load nodes with `href` attribute
|
||||
const hxBoost = getClosestAttribute(node, "hx-boost") === "true"
|
||||
if (node.hasAttribute('href')) {
|
||||
const url = node.getAttribute('href');
|
||||
if (hxBoost) {
|
||||
sendHxGetRequest(url, node);
|
||||
} else {
|
||||
sendXmlGetRequest(url, node);
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Load form elements
|
||||
if (isPreloadableFormElement(node)) {
|
||||
const url = node.form.getAttribute('action')
|
||||
|| node.form.getAttribute('hx-get')
|
||||
|| node.form.getAttribute('data-hx-get');
|
||||
const formData = htmx.values(node.form);
|
||||
const isStandardForm = !(node.form.getAttribute('hx-get')
|
||||
|| node.form.getAttribute('data-hx-get')
|
||||
|| hxBoost);
|
||||
const sendGetRequest = isStandardForm ? sendXmlGetRequest : sendHxGetRequest
|
||||
|
||||
// submit button
|
||||
if (node.type === 'submit') {
|
||||
sendGetRequest(url, node.form, formData)
|
||||
return
|
||||
}
|
||||
|
||||
// select
|
||||
const inputName = node.name || node.control.name;
|
||||
if (node.tagName === 'SELECT') {
|
||||
Array.from(node.options).forEach(option => {
|
||||
if (option.selected) return;
|
||||
formData.set(inputName, option.value);
|
||||
const formDataOrdered = forceFormDataInOrder(node.form, formData);
|
||||
sendGetRequest(url, node.form, formDataOrdered)
|
||||
});
|
||||
return
|
||||
}
|
||||
|
||||
// radio and checkbox
|
||||
const inputType = node.getAttribute("type") || node.control.getAttribute("type");
|
||||
const nodeValue = node.value || node.control?.value;
|
||||
if (inputType === 'radio') {
|
||||
formData.set(inputName, nodeValue);
|
||||
} else if (inputType === 'checkbox'){
|
||||
const inputValues = formData.getAll(inputName);
|
||||
if (inputValues.includes(nodeValue)) {
|
||||
formData[inputName] = inputValues.filter(value => value !== nodeValue);
|
||||
} else {
|
||||
formData.append(inputName, nodeValue);
|
||||
}
|
||||
}
|
||||
const formDataOrdered = forceFormDataInOrder(node.form, formData);
|
||||
sendGetRequest(url, node.form, formDataOrdered)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force formData values to be in the order of form elements.
|
||||
* This is useful to apply after alternating formData values
|
||||
* and before passing them to a HTTP request because cache is
|
||||
* sensitive to GET parameter order e.g., cached `/link?a=1&b=2`
|
||||
* will not be used for `/link?b=2&a=1`.
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {FormData} formData
|
||||
* @returns {FormData}
|
||||
*/
|
||||
function forceFormDataInOrder(form, formData) {
|
||||
const formElements = form.elements;
|
||||
const orderedFormData = new FormData();
|
||||
for(let i = 0; i < formElements.length; i++) {
|
||||
const element = formElements.item(i);
|
||||
if (formData.has(element.name) && element.tagName === 'SELECT') {
|
||||
orderedFormData.append(
|
||||
element.name, formData.get(element.name));
|
||||
continue;
|
||||
}
|
||||
if (formData.has(element.name) && formData.getAll(element.name)
|
||||
.includes(element.value)) {
|
||||
orderedFormData.append(element.name, element.value);
|
||||
}
|
||||
}
|
||||
return orderedFormData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send GET request with `hx-request` headers as if `sourceNode`
|
||||
* target was loaded. Send alternated values if `formData` is set.
|
||||
*
|
||||
* Note that this request is intercepted and sent as XMLHttpRequest.
|
||||
* It is necessary to use `htmx.ajax` to acquire correct headers which
|
||||
* HTMX and extensions add based on `sourceNode`. But it cannot be used
|
||||
* to perform the request due to side-effects e.g. loading indicators.
|
||||
* @param {string} url
|
||||
* @param {Node} sourceNode
|
||||
* @param {FormData=} formData
|
||||
*/
|
||||
function sendHxGetRequest(url, sourceNode, formData = undefined) {
|
||||
htmx.ajax('GET', url, {
|
||||
source: sourceNode,
|
||||
values: formData,
|
||||
headers: {"HX-Preloaded": "true"}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send XML GET request to `url`. Send `formData` as URL params if set.
|
||||
* @param {string} url
|
||||
* @param {Node} sourceNode
|
||||
* @param {FormData=} formData
|
||||
*/
|
||||
function sendXmlGetRequest(url, sourceNode, formData = undefined) {
|
||||
const xhr = new XMLHttpRequest()
|
||||
if (formData) {
|
||||
url += '?' + new URLSearchParams(formData.entries()).toString()
|
||||
}
|
||||
xhr.open('GET', url);
|
||||
xhr.setRequestHeader("HX-Preloaded", "true")
|
||||
xhr.onload = function() { processResponse(sourceNode, xhr.responseText) }
|
||||
xhr.send()
|
||||
}
|
||||
|
||||
/**
|
||||
* Process request response by marking node `DONE` to prevent repeated
|
||||
* requests, except if preload attribute contains `always`,
|
||||
* and load linked resources (e.g. images) returned in the response
|
||||
* if `preload-images` attribute is `true`
|
||||
* @param {Node} node
|
||||
* @param {string} responseText
|
||||
*/
|
||||
function processResponse(node, responseText) {
|
||||
node.preloadState = node.preloadAlways ? 'READY' : 'DONE'
|
||||
|
||||
if (getClosestAttribute(node, 'preload-images') === 'true') {
|
||||
// Load linked resources
|
||||
document.createElement('div').innerHTML = responseText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets attribute value from node or one of its parents
|
||||
* @param {Node} node
|
||||
* @param {string} attribute
|
||||
* @returns { string | undefined }
|
||||
*/
|
||||
function getClosestAttribute(node, attribute) {
|
||||
if (node == undefined) { return undefined }
|
||||
return node.getAttribute(attribute)
|
||||
|| node.getAttribute('data-' + attribute)
|
||||
|| getClosestAttribute(node.parentElement, attribute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if node is valid for preloading and should be
|
||||
* initialized by setting up event listeners and handlers
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidNodeForPreloading(node) {
|
||||
// Add listeners only to nodes which include "GET" transactions
|
||||
// or preloadable "GET" form elements
|
||||
const getReqAttrs = ['href', 'hx-get', 'data-hx-get'];
|
||||
const includesGetRequest = node => getReqAttrs.some(a => node.hasAttribute(a))
|
||||
|| node.method === 'get';
|
||||
const isPreloadableGetFormElement = node.form instanceof HTMLFormElement
|
||||
&& includesGetRequest(node.form)
|
||||
&& isPreloadableFormElement(node)
|
||||
if (!includesGetRequest(node) && !isPreloadableGetFormElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't preload <input> elements contained in <label>
|
||||
// to prevent sending two requests. Interaction on <input> in a
|
||||
// <label><input></input></label> situation activates <label> too.
|
||||
if (node instanceof HTMLInputElement && node.closest('label')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if node is a form element which can be preloaded,
|
||||
* i.e., `radio`, `checkbox`, `select` or `submit` button
|
||||
* or a `label` of a form element which can be preloaded.
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPreloadableFormElement(node) {
|
||||
if (node instanceof HTMLInputElement || node instanceof HTMLButtonElement) {
|
||||
const type = node.getAttribute('type');
|
||||
return ['checkbox', 'radio', 'submit'].includes(type);
|
||||
}
|
||||
if (node instanceof HTMLLabelElement) {
|
||||
return node.control && isPreloadableFormElement(node.control);
|
||||
}
|
||||
return node instanceof HTMLSelectElement;
|
||||
}
|
||||
})()
|
||||
471
views/assets/js/ws.js
Normal file
471
views/assets/js/ws.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('ws', {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// Store reference to internal API
|
||||
api = apiRef
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = 'full-jitter'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
// Try to close the socket when elements are removed
|
||||
case 'htmx:beforeCleanupElement':
|
||||
|
||||
var internalData = api.getInternalData(parent)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close()
|
||||
}
|
||||
return
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case 'htmx:beforeProcessNode':
|
||||
|
||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
|
||||
ensureWebSocket(child)
|
||||
})
|
||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
|
||||
ensureWebSocketSend(child)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/)
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue)
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/)
|
||||
if (value[0] === 'connect') {
|
||||
return value[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
|
||||
|
||||
if (wssSource == null || wssSource === '') {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt)
|
||||
if (legacySource == null) {
|
||||
return
|
||||
} else {
|
||||
wssSource = legacySource
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf('/') === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '')
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = 'wss://' + base_part + wssSource
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = 'ws://' + base_part + wssSource
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function() {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
})
|
||||
|
||||
socketWrapper.addEventListener('message', function(event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
var response = event.data
|
||||
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function(extension) {
|
||||
response = extension.transformResponse(response, null, socketElt)
|
||||
})
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt)
|
||||
var fragment = api.makeFragment(response)
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children)
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks)
|
||||
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
})
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function(event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler)
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
|
||||
this.events[event].push(handler)
|
||||
},
|
||||
|
||||
sendImmediately: function(message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message)
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function(message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message, sendElt })
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt)
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function() {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
|
||||
this.messageQueue.shift()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function() {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc()
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
|
||||
|
||||
this.socket = socket
|
||||
|
||||
socket.onopen = function(e) {
|
||||
wrapper.retryCount = 0
|
||||
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
|
||||
wrapper.handleQueuedMessages()
|
||||
}
|
||||
|
||||
socket.onclose = function(e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
|
||||
setTimeout(function() {
|
||||
wrapper.retryCount += 1
|
||||
wrapper.init()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
|
||||
}
|
||||
|
||||
socket.onerror = function(e) {
|
||||
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
|
||||
maybeCloseWebSocketSource(socketElt)
|
||||
}
|
||||
|
||||
var events = this.events
|
||||
Object.keys(events).forEach(function(k) {
|
||||
events[k].forEach(function(e) {
|
||||
socket.addEventListener(k, e)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
close: function() {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init()
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
}
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt)
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt)
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt)
|
||||
triggerSpecs.forEach(function(ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
|
||||
var results = api.getInputValues(sendElt, 'post')
|
||||
var errors = results.errors
|
||||
var rawParameters = Object.assign({}, results.values)
|
||||
var expressionVars = api.getExpressionVars(sendElt)
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars)
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt)
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers,
|
||||
errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
}
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors)
|
||||
return
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters)
|
||||
if (sendConfig.headers) { toSend.HEADERS = headers }
|
||||
body = JSON.stringify(toSend)
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt)
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount)
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6)
|
||||
var maxDelay = 1000 * Math.pow(2, exp)
|
||||
return maxDelay * Math.random()
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var internalData = api.getInternalData(elt)
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, [])
|
||||
sock.binaryType = htmx.config.wsBinaryType
|
||||
return sock
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
|
||||
result.push(elt)
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1,22 +1,41 @@
|
||||
function a() {
|
||||
document.querySelectorAll("template[simple]").forEach((l) => {
|
||||
let s = l.getAttribute("id"), n = l.content;
|
||||
customElements.define(s, class extends HTMLElement {
|
||||
constructor() {
|
||||
super(), this.appendChild(n.cloneNode(!0)), this.slots = this.querySelectorAll("slot");
|
||||
const p = "[xslt-onload]", T = "xslt-template", i = "xslt-transformed";
|
||||
function a(c) {
|
||||
let o = document.querySelectorAll(p);
|
||||
for (let e of o) {
|
||||
if (e.getAttribute(i) === "true")
|
||||
continue;
|
||||
let r = e.getAttribute(T), t = htmx.find("#" + r);
|
||||
if (t) {
|
||||
let l = t.innerHTML ? new DOMParser().parseFromString(t.innerHTML, "application/xml") : t.contentDocument, s = new XSLTProcessor();
|
||||
s.importStylesheet(l);
|
||||
let n = new DOMParser().parseFromString(e.innerHTML, "application/xml"), m = s.transformToFragment(n, document), u = new XMLSerializer().serializeToString(m);
|
||||
e.innerHTML = u, e.setAttribute(i, !0);
|
||||
} else
|
||||
throw new Error("Unknown XSLT template: " + r);
|
||||
}
|
||||
}
|
||||
function d() {
|
||||
a(), htmx.on("htmx:load", a), document.querySelectorAll("template[simple]").forEach((o) => {
|
||||
let e = o.getAttribute("id"), r = o.content;
|
||||
customElements.define(
|
||||
e,
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super(), this.appendChild(r.cloneNode(!0)), this.slots = this.querySelectorAll("slot");
|
||||
}
|
||||
connectedCallback() {
|
||||
let t = [];
|
||||
this.slots.forEach((l) => {
|
||||
let s = l.getAttribute("name"), n = this.querySelector(`[slot="${s}"]`);
|
||||
n && (l.replaceWith(n.cloneNode(!0)), t.push(n));
|
||||
}), t.forEach((l) => {
|
||||
l.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
connectedCallback() {
|
||||
let o = [];
|
||||
this.slots.forEach((e) => {
|
||||
let r = e.getAttribute("name"), t = this.querySelector(`[slot="${r}"]`);
|
||||
t && (e.replaceWith(t.cloneNode(!0)), o.push(t));
|
||||
}), o.forEach((e) => {
|
||||
e.remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
);
|
||||
});
|
||||
}
|
||||
export {
|
||||
a as setup
|
||||
d as setup
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<script src="/assets/js/alpine.min.js" defer></script>
|
||||
<script src="/assets/js/htmx.min.js" defer></script>
|
||||
<script src="/assets/js/htmx-response-targets.js" defer></script>
|
||||
<script src="/assets/js/client-side-templates.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="/assets/css/fonts.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/assets/style.css" />
|
||||
|
||||
97
views/public/js/class-tools.js
Normal file
97
views/public/js/class-tools.js
Normal file
@@ -0,0 +1,97 @@
|
||||
(function() {
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.split(/\s+/)
|
||||
}
|
||||
|
||||
function parseClassOperation(trimmedValue) {
|
||||
var split = splitOnWhitespace(trimmedValue)
|
||||
if (split.length > 1) {
|
||||
var operation = split[0]
|
||||
var classDef = split[1].trim()
|
||||
var cssClass
|
||||
var delay
|
||||
if (classDef.indexOf(':') > 0) {
|
||||
var splitCssClass = classDef.split(':')
|
||||
cssClass = splitCssClass[0]
|
||||
delay = htmx.parseInterval(splitCssClass[1])
|
||||
} else {
|
||||
cssClass = classDef
|
||||
delay = 100
|
||||
}
|
||||
return {
|
||||
operation,
|
||||
cssClass,
|
||||
delay
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function performOperation(elt, classOperation, classList, currentRunTime) {
|
||||
setTimeout(function() {
|
||||
elt.classList[classOperation.operation].call(elt.classList, classOperation.cssClass)
|
||||
}, currentRunTime)
|
||||
}
|
||||
|
||||
function toggleOperation(elt, classOperation, classList, currentRunTime) {
|
||||
setTimeout(function() {
|
||||
setInterval(function() {
|
||||
elt.classList[classOperation.operation].call(elt.classList, classOperation.cssClass)
|
||||
}, classOperation.delay)
|
||||
}, currentRunTime)
|
||||
}
|
||||
|
||||
function processClassList(elt, classList) {
|
||||
var runs = classList.split('&')
|
||||
for (var i = 0; i < runs.length; i++) {
|
||||
var run = runs[i]
|
||||
var currentRunTime = 0
|
||||
var classOperations = run.split(',')
|
||||
for (var j = 0; j < classOperations.length; j++) {
|
||||
var value = classOperations[j]
|
||||
var trimmedValue = value.trim()
|
||||
var classOperation = parseClassOperation(trimmedValue)
|
||||
if (classOperation) {
|
||||
if (classOperation.operation === 'toggle') {
|
||||
toggleOperation(elt, classOperation, classList, currentRunTime)
|
||||
currentRunTime = currentRunTime + classOperation.delay
|
||||
} else {
|
||||
currentRunTime = currentRunTime + classOperation.delay
|
||||
performOperation(elt, classOperation, classList, currentRunTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function maybeProcessClasses(elt) {
|
||||
if (elt.getAttribute) {
|
||||
var classList = elt.getAttribute('classes') || elt.getAttribute('data-classes')
|
||||
if (classList) {
|
||||
processClassList(elt, classList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension('class-tools', {
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:afterProcessNode') {
|
||||
var elt = evt.detail.elt
|
||||
maybeProcessClasses(elt)
|
||||
var classList = elt.getAttribute("apply-parent-classes") || elt.getAttribute("data-apply-parent-classes");
|
||||
if (classList) {
|
||||
var parent = elt.parentElement;
|
||||
parent.removeChild(elt);
|
||||
parent.setAttribute("classes", classList);
|
||||
maybeProcessClasses(parent);
|
||||
} else if (elt.querySelectorAll) {
|
||||
var children = elt.querySelectorAll('[classes], [data-classes]')
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
maybeProcessClasses(children[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
96
views/public/js/client-side-templates.js
Normal file
96
views/public/js/client-side-templates.js
Normal file
@@ -0,0 +1,96 @@
|
||||
htmx.defineExtension('client-side-templates', {
|
||||
transformResponse: function(text, xhr, elt) {
|
||||
var mustacheTemplate = htmx.closest(elt, '[mustache-template]')
|
||||
if (mustacheTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateId = mustacheTemplate.getAttribute('mustache-template')
|
||||
var template = htmx.find('#' + templateId)
|
||||
if (template) {
|
||||
return Mustache.render(template.innerHTML, data)
|
||||
} else {
|
||||
throw new Error('Unknown mustache template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var mustacheArrayTemplate = htmx.closest(elt, '[mustache-array-template]')
|
||||
if (mustacheArrayTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateId = mustacheArrayTemplate.getAttribute('mustache-array-template')
|
||||
var template = htmx.find('#' + templateId)
|
||||
if (template) {
|
||||
return Mustache.render(template.innerHTML, { data })
|
||||
} else {
|
||||
throw new Error('Unknown mustache template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var handlebarsTemplate = htmx.closest(elt, '[handlebars-template]')
|
||||
if (handlebarsTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateId = handlebarsTemplate.getAttribute('handlebars-template')
|
||||
var templateElement = htmx.find('#' + templateId).innerHTML
|
||||
var renderTemplate = Handlebars.compile(templateElement)
|
||||
if (renderTemplate) {
|
||||
return renderTemplate(data)
|
||||
} else {
|
||||
throw new Error('Unknown handlebars template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var handlebarsArrayTemplate = htmx.closest(elt, '[handlebars-array-template]')
|
||||
if (handlebarsArrayTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateId = handlebarsArrayTemplate.getAttribute('handlebars-array-template')
|
||||
var templateElement = htmx.find('#' + templateId).innerHTML
|
||||
var renderTemplate = Handlebars.compile(templateElement)
|
||||
if (renderTemplate) {
|
||||
return renderTemplate(data)
|
||||
} else {
|
||||
throw new Error('Unknown handlebars template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var nunjucksTemplate = htmx.closest(elt, '[nunjucks-template]')
|
||||
if (nunjucksTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateName = nunjucksTemplate.getAttribute('nunjucks-template')
|
||||
var template = htmx.find('#' + templateName)
|
||||
if (template) {
|
||||
return nunjucks.renderString(template.innerHTML, data)
|
||||
} else {
|
||||
return nunjucks.render(templateName, data)
|
||||
}
|
||||
}
|
||||
|
||||
var xsltTemplate = htmx.closest(elt, '[xslt-template]')
|
||||
if (xsltTemplate) {
|
||||
var templateId = xsltTemplate.getAttribute('xslt-template')
|
||||
var template = htmx.find('#' + templateId)
|
||||
if (template) {
|
||||
var content = template.innerHTML
|
||||
? new DOMParser().parseFromString(template.innerHTML, 'application/xml')
|
||||
: template.contentDocument
|
||||
var processor = new XSLTProcessor()
|
||||
processor.importStylesheet(content)
|
||||
var data = new DOMParser().parseFromString(text, 'application/xml')
|
||||
var frag = processor.transformToFragment(data, document)
|
||||
return new XMLSerializer().serializeToString(frag)
|
||||
} else {
|
||||
throw new Error('Unknown XSLT template: ' + templateId)
|
||||
}
|
||||
}
|
||||
|
||||
var nunjucksArrayTemplate = htmx.closest(elt, '[nunjucks-array-template]')
|
||||
if (nunjucksArrayTemplate) {
|
||||
var data = JSON.parse(text)
|
||||
var templateName = nunjucksArrayTemplate.getAttribute('nunjucks-array-template')
|
||||
var template = htmx.find('#' + templateName)
|
||||
if (template) {
|
||||
return nunjucks.renderString(template.innerHTML, { data })
|
||||
} else {
|
||||
return nunjucks.render(templateName, { data })
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
})
|
||||
144
views/public/js/head-support.js
Normal file
144
views/public/js/head-support.js
Normal file
@@ -0,0 +1,144 @@
|
||||
//==========================================================
|
||||
// head-support.js
|
||||
//
|
||||
// An extension to add head tag merging.
|
||||
//==========================================================
|
||||
(function(){
|
||||
|
||||
var api = null;
|
||||
|
||||
function log() {
|
||||
//console.log(arguments);
|
||||
}
|
||||
|
||||
function mergeHead(newContent, defaultMergeStrategy) {
|
||||
|
||||
if (newContent && newContent.indexOf('<head') > -1) {
|
||||
const htmlDoc = document.createElement("html");
|
||||
// remove svgs to avoid conflicts
|
||||
var contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
||||
// extract head tag
|
||||
var headTag = contentWithSvgsRemoved.match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);
|
||||
|
||||
// if the head tag exists...
|
||||
if (headTag) {
|
||||
|
||||
var added = []
|
||||
var removed = []
|
||||
var preserved = []
|
||||
var nodesToAppend = []
|
||||
|
||||
htmlDoc.innerHTML = headTag;
|
||||
var newHeadTag = htmlDoc.querySelector("head");
|
||||
var currentHead = document.head;
|
||||
|
||||
if (newHeadTag == null) {
|
||||
return;
|
||||
} else {
|
||||
// put all new head elements into a Map, by their outerHTML
|
||||
var srcToNewHeadNodes = new Map();
|
||||
for (const newHeadChild of newHeadTag.children) {
|
||||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// determine merge strategy
|
||||
var mergeStrategy = api.getAttributeValue(newHeadTag, "hx-head") || defaultMergeStrategy;
|
||||
|
||||
// get the current head
|
||||
for (const currentHeadElt of currentHead.children) {
|
||||
|
||||
// If the current head element is in the map
|
||||
var inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
||||
var isReAppended = currentHeadElt.getAttribute("hx-head") === "re-eval";
|
||||
var isPreserved = api.getAttributeValue(currentHeadElt, "hx-preserve") === "true";
|
||||
if (inNewContent || isPreserved) {
|
||||
if (isReAppended) {
|
||||
// remove the current version and let the new version replace it and re-execute
|
||||
removed.push(currentHeadElt);
|
||||
} else {
|
||||
// this element already exists and should not be re-appended, so remove it from
|
||||
// the new content map, preserving it in the DOM
|
||||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
||||
preserved.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
if (mergeStrategy === "append") {
|
||||
// we are appending and this existing element is not new content
|
||||
// so if and only if it is marked for re-append do we do anything
|
||||
if (isReAppended) {
|
||||
removed.push(currentHeadElt);
|
||||
nodesToAppend.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
// if this is a merge, we remove this content since it is not in the new head
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: currentHeadElt}) !== false) {
|
||||
removed.push(currentHeadElt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push the tremaining new head elements in the Map into the
|
||||
// nodes to append to the head tag
|
||||
nodesToAppend.push(...srcToNewHeadNodes.values());
|
||||
log("to append: ", nodesToAppend);
|
||||
|
||||
for (const newNode of nodesToAppend) {
|
||||
log("adding: ", newNode);
|
||||
var newElt = document.createRange().createContextualFragment(newNode.outerHTML);
|
||||
log(newElt);
|
||||
if (api.triggerEvent(document.body, "htmx:addingHeadElement", {headElement: newElt}) !== false) {
|
||||
currentHead.appendChild(newElt);
|
||||
added.push(newElt);
|
||||
}
|
||||
}
|
||||
|
||||
// remove all removed elements, after we have appended the new elements to avoid
|
||||
// additional network requests for things like style sheets
|
||||
for (const removedElement of removed) {
|
||||
if (api.triggerEvent(document.body, "htmx:removingHeadElement", {headElement: removedElement}) !== false) {
|
||||
currentHead.removeChild(removedElement);
|
||||
}
|
||||
}
|
||||
|
||||
api.triggerEvent(document.body, "htmx:afterHeadMerge", {added: added, kept: preserved, removed: removed});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension("head-support", {
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef;
|
||||
|
||||
htmx.on('htmx:afterSwap', function(evt){
|
||||
let xhr = evt.detail.xhr;
|
||||
if (xhr) {
|
||||
var serverResponse = xhr.response;
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
mergeHead(serverResponse, evt.detail.boosted ? "merge" : "append");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyRestore', function(evt){
|
||||
if (api.triggerEvent(document.body, "htmx:beforeHeadMerge", evt.detail)) {
|
||||
if (evt.detail.cacheMiss) {
|
||||
mergeHead(evt.detail.serverResponse, "merge");
|
||||
} else {
|
||||
mergeHead(evt.detail.item.head, "merge");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
htmx.on('htmx:historyItemCreated', function(evt){
|
||||
var historyItem = evt.detail.item;
|
||||
historyItem.head = document.head.outerHTML;
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
})()
|
||||
23
views/public/js/include-vals.js
Normal file
23
views/public/js/include-vals.js
Normal file
@@ -0,0 +1,23 @@
|
||||
(function() {
|
||||
function mergeObjects(obj1, obj2) {
|
||||
for (var key in obj2) {
|
||||
if (obj2.hasOwnProperty(key)) {
|
||||
obj1[key] = obj2[key]
|
||||
}
|
||||
}
|
||||
return obj1
|
||||
}
|
||||
|
||||
htmx.defineExtension('include-vals', {
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:configRequest') {
|
||||
var includeValsElt = htmx.closest(evt.detail.elt, '[include-vals],[data-include-vals]')
|
||||
if (includeValsElt) {
|
||||
var includeVals = includeValsElt.getAttribute('include-vals') || includeValsElt.getAttribute('data-include-vals')
|
||||
var valuesToInclude = eval('({' + includeVals + '})')
|
||||
mergeObjects(evt.detail.parameters, valuesToInclude)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
184
views/public/js/loading-states.js
Normal file
184
views/public/js/loading-states.js
Normal file
@@ -0,0 +1,184 @@
|
||||
;(function() {
|
||||
const loadingStatesUndoQueue = []
|
||||
|
||||
function loadingStateContainer(target) {
|
||||
return htmx.closest(target, '[data-loading-states]') || document.body
|
||||
}
|
||||
|
||||
function mayProcessUndoCallback(target, callback) {
|
||||
if (document.body.contains(target)) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
function mayProcessLoadingStateByPath(elt, requestPath) {
|
||||
const pathElt = htmx.closest(elt, '[data-loading-path]')
|
||||
if (!pathElt) {
|
||||
return true
|
||||
}
|
||||
|
||||
return pathElt.getAttribute('data-loading-path') === requestPath
|
||||
}
|
||||
|
||||
function queueLoadingState(sourceElt, targetElt, doCallback, undoCallback) {
|
||||
const delayElt = htmx.closest(sourceElt, '[data-loading-delay]')
|
||||
if (delayElt) {
|
||||
const delayInMilliseconds =
|
||||
delayElt.getAttribute('data-loading-delay') || 200
|
||||
const timeout = setTimeout(function() {
|
||||
doCallback()
|
||||
|
||||
loadingStatesUndoQueue.push(function() {
|
||||
mayProcessUndoCallback(targetElt, undoCallback)
|
||||
})
|
||||
}, delayInMilliseconds)
|
||||
|
||||
loadingStatesUndoQueue.push(function() {
|
||||
mayProcessUndoCallback(targetElt, function() { clearTimeout(timeout) })
|
||||
})
|
||||
} else {
|
||||
doCallback()
|
||||
loadingStatesUndoQueue.push(function() {
|
||||
mayProcessUndoCallback(targetElt, undoCallback)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getLoadingStateElts(loadingScope, type, path) {
|
||||
return Array.from(htmx.findAll(loadingScope, '[' + type + ']')).filter(
|
||||
function(elt) { return mayProcessLoadingStateByPath(elt, path) }
|
||||
)
|
||||
}
|
||||
|
||||
function getLoadingTarget(elt) {
|
||||
if (elt.getAttribute('data-loading-target')) {
|
||||
return Array.from(
|
||||
htmx.findAll(elt.getAttribute('data-loading-target'))
|
||||
)
|
||||
}
|
||||
return [elt]
|
||||
}
|
||||
|
||||
htmx.defineExtension('loading-states', {
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:beforeRequest') {
|
||||
const container = loadingStateContainer(evt.target)
|
||||
|
||||
const loadingStateTypes = [
|
||||
'data-loading',
|
||||
'data-loading-class',
|
||||
'data-loading-class-remove',
|
||||
'data-loading-disable',
|
||||
'data-loading-aria-busy'
|
||||
]
|
||||
|
||||
const loadingStateEltsByType = {}
|
||||
|
||||
loadingStateTypes.forEach(function(type) {
|
||||
loadingStateEltsByType[type] = getLoadingStateElts(
|
||||
container,
|
||||
type,
|
||||
evt.detail.pathInfo.requestPath
|
||||
)
|
||||
})
|
||||
|
||||
loadingStateEltsByType['data-loading'].forEach(function(sourceElt) {
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() {
|
||||
targetElt.style.display =
|
||||
sourceElt.getAttribute('data-loading') ||
|
||||
'inline-block'
|
||||
},
|
||||
function() { targetElt.style.display = 'none' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
loadingStateEltsByType['data-loading-class'].forEach(
|
||||
function(sourceElt) {
|
||||
const classNames = sourceElt
|
||||
.getAttribute('data-loading-class')
|
||||
.split(' ')
|
||||
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() {
|
||||
classNames.forEach(function(className) {
|
||||
targetElt.classList.add(className)
|
||||
})
|
||||
},
|
||||
function() {
|
||||
classNames.forEach(function(className) {
|
||||
targetElt.classList.remove(className)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
loadingStateEltsByType['data-loading-class-remove'].forEach(
|
||||
function(sourceElt) {
|
||||
const classNames = sourceElt
|
||||
.getAttribute('data-loading-class-remove')
|
||||
.split(' ')
|
||||
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() {
|
||||
classNames.forEach(function(className) {
|
||||
targetElt.classList.remove(className)
|
||||
})
|
||||
},
|
||||
function() {
|
||||
classNames.forEach(function(className) {
|
||||
targetElt.classList.add(className)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
loadingStateEltsByType['data-loading-disable'].forEach(
|
||||
function(sourceElt) {
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() { targetElt.disabled = true },
|
||||
function() { targetElt.disabled = false }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
loadingStateEltsByType['data-loading-aria-busy'].forEach(
|
||||
function(sourceElt) {
|
||||
getLoadingTarget(sourceElt).forEach(function(targetElt) {
|
||||
queueLoadingState(
|
||||
sourceElt,
|
||||
targetElt,
|
||||
function() { targetElt.setAttribute('aria-busy', 'true') },
|
||||
function() { targetElt.removeAttribute('aria-busy') }
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (name === 'htmx:beforeOnLoad') {
|
||||
while (loadingStatesUndoQueue.length > 0) {
|
||||
loadingStatesUndoQueue.shift()()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
44
views/public/js/multi-swap.js
Normal file
44
views/public/js/multi-swap.js
Normal file
@@ -0,0 +1,44 @@
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('multi-swap', {
|
||||
init: function(apiRef) {
|
||||
api = apiRef
|
||||
},
|
||||
isInlineSwap: function(swapStyle) {
|
||||
return swapStyle.indexOf('multi:') === 0
|
||||
},
|
||||
handleSwap: function(swapStyle, target, fragment, settleInfo) {
|
||||
if (swapStyle.indexOf('multi:') === 0) {
|
||||
var selectorToSwapStyle = {}
|
||||
var elements = swapStyle.replace(/^multi\s*:\s*/, '').split(/\s*,\s*/)
|
||||
|
||||
elements.forEach(function(element) {
|
||||
var split = element.split(/\s*:\s*/)
|
||||
var elementSelector = split[0]
|
||||
var elementSwapStyle = typeof (split[1]) !== 'undefined' ? split[1] : 'innerHTML'
|
||||
|
||||
if (elementSelector.charAt(0) !== '#') {
|
||||
console.error("HTMX multi-swap: unsupported selector '" + elementSelector + "'. Only ID selectors starting with '#' are supported.")
|
||||
return
|
||||
}
|
||||
|
||||
selectorToSwapStyle[elementSelector] = elementSwapStyle
|
||||
})
|
||||
|
||||
for (var selector in selectorToSwapStyle) {
|
||||
var swapStyle = selectorToSwapStyle[selector]
|
||||
var elementToSwap = fragment.querySelector(selector)
|
||||
if (elementToSwap) {
|
||||
api.oobSwap(swapStyle, elementToSwap, settleInfo)
|
||||
} else {
|
||||
console.warn("HTMX multi-swap: selector '" + selector + "' not found in source content.")
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
11
views/public/js/path-params.js
Normal file
11
views/public/js/path-params.js
Normal file
@@ -0,0 +1,11 @@
|
||||
htmx.defineExtension('path-params', {
|
||||
onEvent: function(name, evt) {
|
||||
if (name === 'htmx:configRequest') {
|
||||
evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function(_, param) {
|
||||
var val = evt.detail.parameters[param]
|
||||
delete evt.detail.parameters[param]
|
||||
return val === undefined ? '{' + param + '}' : encodeURIComponent(val)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
388
views/public/js/preload.js
Normal file
388
views/public/js/preload.js
Normal file
@@ -0,0 +1,388 @@
|
||||
(function() {
|
||||
/**
|
||||
* This adds the "preload" extension to htmx. The extension will
|
||||
* preload the targets of elements with "preload" attribute if:
|
||||
* - they also have `href`, `hx-get` or `data-hx-get` attributes
|
||||
* - they are radio buttons, checkboxes, select elements and submit
|
||||
* buttons of forms with `method="get"` or `hx-get` attributes
|
||||
* The extension relies on browser cache and for it to work
|
||||
* server response must include `Cache-Control` header
|
||||
* e.g. `Cache-Control: private, max-age=60`.
|
||||
* For more details @see https://htmx.org/extensions/preload/
|
||||
*/
|
||||
|
||||
htmx.defineExtension('preload', {
|
||||
onEvent: function(name, event) {
|
||||
// Process preload attributes on `htmx:afterProcessNode`
|
||||
if (name === 'htmx:afterProcessNode') {
|
||||
// Initialize all nodes with `preload` attribute
|
||||
const parent = event.target || event.detail.elt;
|
||||
const preloadNodes = [
|
||||
...parent.hasAttribute("preload") ? [parent] : [],
|
||||
...parent.querySelectorAll("[preload]")]
|
||||
preloadNodes.forEach(function(node) {
|
||||
// Initialize the node with the `preload` attribute
|
||||
init(node)
|
||||
|
||||
// Initialize all child elements which has
|
||||
// `href`, `hx-get` or `data-hx-get` attributes
|
||||
node.querySelectorAll('[href],[hx-get],[data-hx-get]').forEach(init)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Intercept HTMX preload requests on `htmx:beforeRequest` and
|
||||
// send them as XHR requests instead to avoid side-effects,
|
||||
// such as showing loading indicators while preloading data.
|
||||
if (name === 'htmx:beforeRequest') {
|
||||
const requestHeaders = event.detail.requestConfig.headers
|
||||
if (!("HX-Preloaded" in requestHeaders
|
||||
&& requestHeaders["HX-Preloaded"] === "true")) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
// Reuse XHR created by HTMX with replaced callbacks
|
||||
const xhr = event.detail.xhr
|
||||
xhr.onload = function() {
|
||||
processResponse(event.detail.elt, xhr.responseText)
|
||||
}
|
||||
xhr.onerror = null
|
||||
xhr.onabort = null
|
||||
xhr.ontimeout = null
|
||||
xhr.send()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Initialize `node`, set up event handlers based on own or inherited
|
||||
* `preload` attributes and set `node.preloadState` to `READY`.
|
||||
*
|
||||
* `node.preloadState` can have these values:
|
||||
* - `READY` - event handlers have been set up and node is ready to preload
|
||||
* - `TIMEOUT` - a triggering event has been fired, but `node` is not
|
||||
* yet being loaded because some time need to pass first e.g. user
|
||||
* has to keep hovering over an element for 100ms for preload to start
|
||||
* - `LOADING` means that `node` is in the process of being preloaded
|
||||
* - `DONE` means that the preloading process is complete and `node`
|
||||
* doesn't need a repeated preload (indicated by preload="always")
|
||||
* @param {Node} node
|
||||
*/
|
||||
function init(node) {
|
||||
// Guarantee that each node is initialized only once
|
||||
if (node.preloadState !== undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidNodeForPreloading(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize form element preloading
|
||||
if (node instanceof HTMLFormElement) {
|
||||
const form = node
|
||||
// Only initialize forms with `method="get"` or `hx-get` attributes
|
||||
if (!((form.hasAttribute('method') && form.method === 'get')
|
||||
|| form.hasAttribute('hx-get') || form.hasAttribute('hx-data-get'))) {
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < form.elements.length; i++) {
|
||||
const element = form.elements.item(i);
|
||||
init(element);
|
||||
element.labels.forEach(init);
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Process node configuration from preload attribute
|
||||
let preloadAttr = getClosestAttribute(node, 'preload');
|
||||
node.preloadAlways = preloadAttr && preloadAttr.includes('always');
|
||||
if (node.preloadAlways) {
|
||||
preloadAttr = preloadAttr.replace('always', '').trim();
|
||||
}
|
||||
let triggerEventName = preloadAttr || 'mousedown';
|
||||
|
||||
// Set up event handlers listening for triggering events
|
||||
const needsTimeout = triggerEventName === 'mouseover'
|
||||
node.addEventListener(triggerEventName, getEventHandler(node, needsTimeout))
|
||||
|
||||
// Add `touchstart` listener for touchscreen support
|
||||
// if `mousedown` or `mouseover` is used
|
||||
if (triggerEventName === 'mousedown' || triggerEventName === 'mouseover') {
|
||||
node.addEventListener('touchstart', getEventHandler(node))
|
||||
}
|
||||
|
||||
// If `mouseover` is used, set up `mouseout` listener,
|
||||
// which will abort preloading if user moves mouse outside
|
||||
// the element in less than 100ms after hovering over it
|
||||
if (triggerEventName === 'mouseover') {
|
||||
node.addEventListener('mouseout', function(evt) {
|
||||
if ((evt.target === node) && (node.preloadState === 'TIMEOUT')) {
|
||||
node.preloadState = 'READY'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mark the node as ready to be preloaded
|
||||
node.preloadState = 'READY'
|
||||
|
||||
// This event can be used to load content immediately
|
||||
htmx.trigger(node, 'preload:init')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return event handler which can be called by event listener to start
|
||||
* the preloading process of `node` with or without a timeout
|
||||
* @param {Node} node
|
||||
* @param {boolean=} needsTimeout
|
||||
* @returns {function(): void}
|
||||
*/
|
||||
function getEventHandler(node, needsTimeout = false) {
|
||||
return function() {
|
||||
// Do not preload uninitialized nodes, nodes which are in process
|
||||
// of being preloaded or have been preloaded and don't need repeat
|
||||
if (node.preloadState !== 'READY') {
|
||||
return
|
||||
}
|
||||
|
||||
if (needsTimeout) {
|
||||
node.preloadState = 'TIMEOUT'
|
||||
const timeoutMs = 100
|
||||
window.setTimeout(function() {
|
||||
if (node.preloadState === 'TIMEOUT') {
|
||||
node.preloadState = 'READY'
|
||||
load(node)
|
||||
}
|
||||
}, timeoutMs)
|
||||
return
|
||||
}
|
||||
|
||||
load(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload the target of node, which can be:
|
||||
* - hx-get or data-hx-get attribute
|
||||
* - href or form action attribute
|
||||
* @param {Node} node
|
||||
*/
|
||||
function load(node) {
|
||||
// Do not preload uninitialized nodes, nodes which are in process
|
||||
// of being preloaded or have been preloaded and don't need repeat
|
||||
if (node.preloadState !== 'READY') {
|
||||
return
|
||||
}
|
||||
node.preloadState = 'LOADING'
|
||||
|
||||
// Load nodes with `hx-get` or `data-hx-get` attribute
|
||||
// Forms don't reach this because only their elements are initialized
|
||||
const hxGet = node.getAttribute('hx-get') || node.getAttribute('data-hx-get')
|
||||
if (hxGet) {
|
||||
sendHxGetRequest(hxGet, node);
|
||||
return
|
||||
}
|
||||
|
||||
// Load nodes with `href` attribute
|
||||
const hxBoost = getClosestAttribute(node, "hx-boost") === "true"
|
||||
if (node.hasAttribute('href')) {
|
||||
const url = node.getAttribute('href');
|
||||
if (hxBoost) {
|
||||
sendHxGetRequest(url, node);
|
||||
} else {
|
||||
sendXmlGetRequest(url, node);
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Load form elements
|
||||
if (isPreloadableFormElement(node)) {
|
||||
const url = node.form.getAttribute('action')
|
||||
|| node.form.getAttribute('hx-get')
|
||||
|| node.form.getAttribute('data-hx-get');
|
||||
const formData = htmx.values(node.form);
|
||||
const isStandardForm = !(node.form.getAttribute('hx-get')
|
||||
|| node.form.getAttribute('data-hx-get')
|
||||
|| hxBoost);
|
||||
const sendGetRequest = isStandardForm ? sendXmlGetRequest : sendHxGetRequest
|
||||
|
||||
// submit button
|
||||
if (node.type === 'submit') {
|
||||
sendGetRequest(url, node.form, formData)
|
||||
return
|
||||
}
|
||||
|
||||
// select
|
||||
const inputName = node.name || node.control.name;
|
||||
if (node.tagName === 'SELECT') {
|
||||
Array.from(node.options).forEach(option => {
|
||||
if (option.selected) return;
|
||||
formData.set(inputName, option.value);
|
||||
const formDataOrdered = forceFormDataInOrder(node.form, formData);
|
||||
sendGetRequest(url, node.form, formDataOrdered)
|
||||
});
|
||||
return
|
||||
}
|
||||
|
||||
// radio and checkbox
|
||||
const inputType = node.getAttribute("type") || node.control.getAttribute("type");
|
||||
const nodeValue = node.value || node.control?.value;
|
||||
if (inputType === 'radio') {
|
||||
formData.set(inputName, nodeValue);
|
||||
} else if (inputType === 'checkbox'){
|
||||
const inputValues = formData.getAll(inputName);
|
||||
if (inputValues.includes(nodeValue)) {
|
||||
formData[inputName] = inputValues.filter(value => value !== nodeValue);
|
||||
} else {
|
||||
formData.append(inputName, nodeValue);
|
||||
}
|
||||
}
|
||||
const formDataOrdered = forceFormDataInOrder(node.form, formData);
|
||||
sendGetRequest(url, node.form, formDataOrdered)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force formData values to be in the order of form elements.
|
||||
* This is useful to apply after alternating formData values
|
||||
* and before passing them to a HTTP request because cache is
|
||||
* sensitive to GET parameter order e.g., cached `/link?a=1&b=2`
|
||||
* will not be used for `/link?b=2&a=1`.
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {FormData} formData
|
||||
* @returns {FormData}
|
||||
*/
|
||||
function forceFormDataInOrder(form, formData) {
|
||||
const formElements = form.elements;
|
||||
const orderedFormData = new FormData();
|
||||
for(let i = 0; i < formElements.length; i++) {
|
||||
const element = formElements.item(i);
|
||||
if (formData.has(element.name) && element.tagName === 'SELECT') {
|
||||
orderedFormData.append(
|
||||
element.name, formData.get(element.name));
|
||||
continue;
|
||||
}
|
||||
if (formData.has(element.name) && formData.getAll(element.name)
|
||||
.includes(element.value)) {
|
||||
orderedFormData.append(element.name, element.value);
|
||||
}
|
||||
}
|
||||
return orderedFormData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send GET request with `hx-request` headers as if `sourceNode`
|
||||
* target was loaded. Send alternated values if `formData` is set.
|
||||
*
|
||||
* Note that this request is intercepted and sent as XMLHttpRequest.
|
||||
* It is necessary to use `htmx.ajax` to acquire correct headers which
|
||||
* HTMX and extensions add based on `sourceNode`. But it cannot be used
|
||||
* to perform the request due to side-effects e.g. loading indicators.
|
||||
* @param {string} url
|
||||
* @param {Node} sourceNode
|
||||
* @param {FormData=} formData
|
||||
*/
|
||||
function sendHxGetRequest(url, sourceNode, formData = undefined) {
|
||||
htmx.ajax('GET', url, {
|
||||
source: sourceNode,
|
||||
values: formData,
|
||||
headers: {"HX-Preloaded": "true"}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send XML GET request to `url`. Send `formData` as URL params if set.
|
||||
* @param {string} url
|
||||
* @param {Node} sourceNode
|
||||
* @param {FormData=} formData
|
||||
*/
|
||||
function sendXmlGetRequest(url, sourceNode, formData = undefined) {
|
||||
const xhr = new XMLHttpRequest()
|
||||
if (formData) {
|
||||
url += '?' + new URLSearchParams(formData.entries()).toString()
|
||||
}
|
||||
xhr.open('GET', url);
|
||||
xhr.setRequestHeader("HX-Preloaded", "true")
|
||||
xhr.onload = function() { processResponse(sourceNode, xhr.responseText) }
|
||||
xhr.send()
|
||||
}
|
||||
|
||||
/**
|
||||
* Process request response by marking node `DONE` to prevent repeated
|
||||
* requests, except if preload attribute contains `always`,
|
||||
* and load linked resources (e.g. images) returned in the response
|
||||
* if `preload-images` attribute is `true`
|
||||
* @param {Node} node
|
||||
* @param {string} responseText
|
||||
*/
|
||||
function processResponse(node, responseText) {
|
||||
node.preloadState = node.preloadAlways ? 'READY' : 'DONE'
|
||||
|
||||
if (getClosestAttribute(node, 'preload-images') === 'true') {
|
||||
// Load linked resources
|
||||
document.createElement('div').innerHTML = responseText
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets attribute value from node or one of its parents
|
||||
* @param {Node} node
|
||||
* @param {string} attribute
|
||||
* @returns { string | undefined }
|
||||
*/
|
||||
function getClosestAttribute(node, attribute) {
|
||||
if (node == undefined) { return undefined }
|
||||
return node.getAttribute(attribute)
|
||||
|| node.getAttribute('data-' + attribute)
|
||||
|| getClosestAttribute(node.parentElement, attribute)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if node is valid for preloading and should be
|
||||
* initialized by setting up event listeners and handlers
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidNodeForPreloading(node) {
|
||||
// Add listeners only to nodes which include "GET" transactions
|
||||
// or preloadable "GET" form elements
|
||||
const getReqAttrs = ['href', 'hx-get', 'data-hx-get'];
|
||||
const includesGetRequest = node => getReqAttrs.some(a => node.hasAttribute(a))
|
||||
|| node.method === 'get';
|
||||
const isPreloadableGetFormElement = node.form instanceof HTMLFormElement
|
||||
&& includesGetRequest(node.form)
|
||||
&& isPreloadableFormElement(node)
|
||||
if (!includesGetRequest(node) && !isPreloadableGetFormElement) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't preload <input> elements contained in <label>
|
||||
// to prevent sending two requests. Interaction on <input> in a
|
||||
// <label><input></input></label> situation activates <label> too.
|
||||
if (node instanceof HTMLInputElement && node.closest('label')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if node is a form element which can be preloaded,
|
||||
* i.e., `radio`, `checkbox`, `select` or `submit` button
|
||||
* or a `label` of a form element which can be preloaded.
|
||||
* @param {Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPreloadableFormElement(node) {
|
||||
if (node instanceof HTMLInputElement || node instanceof HTMLButtonElement) {
|
||||
const type = node.getAttribute('type');
|
||||
return ['checkbox', 'radio', 'submit'].includes(type);
|
||||
}
|
||||
if (node instanceof HTMLLabelElement) {
|
||||
return node.control && isPreloadableFormElement(node.control);
|
||||
}
|
||||
return node instanceof HTMLSelectElement;
|
||||
}
|
||||
})()
|
||||
471
views/public/js/ws.js
Normal file
471
views/public/js/ws.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('ws', {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// Store reference to internal API
|
||||
api = apiRef
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = 'full-jitter'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
// Try to close the socket when elements are removed
|
||||
case 'htmx:beforeCleanupElement':
|
||||
|
||||
var internalData = api.getInternalData(parent)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close()
|
||||
}
|
||||
return
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case 'htmx:beforeProcessNode':
|
||||
|
||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), function(child) {
|
||||
ensureWebSocket(child)
|
||||
})
|
||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), function(child) {
|
||||
ensureWebSocketSend(child)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/)
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, 'hx-ws')
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue)
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/)
|
||||
if (value[0] === 'connect') {
|
||||
return value[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, 'ws-connect')
|
||||
|
||||
if (wssSource == null || wssSource === '') {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt)
|
||||
if (legacySource == null) {
|
||||
return
|
||||
} else {
|
||||
wssSource = legacySource
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf('/') === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '')
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = 'wss://' + base_part + wssSource
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = 'ws://' + base_part + wssSource
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function() {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
})
|
||||
|
||||
socketWrapper.addEventListener('message', function(event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
var response = event.data
|
||||
if (!api.triggerEvent(socketElt, 'htmx:wsBeforeMessage', {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function(extension) {
|
||||
response = extension.transformResponse(response, null, socketElt)
|
||||
})
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt)
|
||||
var fragment = api.makeFragment(response)
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children)
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], 'hx-swap-oob') || 'true', children[i], settleInfo)
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks)
|
||||
api.triggerEvent(socketElt, 'htmx:wsAfterMessage', { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
})
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function(event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler)
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = []
|
||||
}
|
||||
|
||||
this.events[event].push(handler)
|
||||
},
|
||||
|
||||
sendImmediately: function(message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message)
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function(message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message, sendElt })
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt)
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function() {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt)
|
||||
this.messageQueue.shift()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function() {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc()
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, 'htmx:wsConnecting', { event: { type: 'connecting' } })
|
||||
|
||||
this.socket = socket
|
||||
|
||||
socket.onopen = function(e) {
|
||||
wrapper.retryCount = 0
|
||||
api.triggerEvent(socketElt, 'htmx:wsOpen', { event: e, socketWrapper: wrapper.publicInterface })
|
||||
wrapper.handleQueuedMessages()
|
||||
}
|
||||
|
||||
socket.onclose = function(e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount)
|
||||
setTimeout(function() {
|
||||
wrapper.retryCount += 1
|
||||
wrapper.init()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, 'htmx:wsClose', { event: e, socketWrapper: wrapper.publicInterface })
|
||||
}
|
||||
|
||||
socket.onerror = function(e) {
|
||||
api.triggerErrorEvent(socketElt, 'htmx:wsError', { error: e, socketWrapper: wrapper })
|
||||
maybeCloseWebSocketSource(socketElt)
|
||||
}
|
||||
|
||||
var events = this.events
|
||||
Object.keys(events).forEach(function(k) {
|
||||
events[k].forEach(function(e) {
|
||||
socket.addEventListener(k, e)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
close: function() {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init()
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
}
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, 'hx-ws')
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt)
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt)
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt)
|
||||
triggerSpecs.forEach(function(ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function(elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt))
|
||||
var results = api.getInputValues(sendElt, 'post')
|
||||
var errors = results.errors
|
||||
var rawParameters = Object.assign({}, results.values)
|
||||
var expressionVars = api.getExpressionVars(sendElt)
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars)
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt)
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers,
|
||||
errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
}
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors)
|
||||
return
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters)
|
||||
if (sendConfig.headers) { toSend.HEADERS = headers }
|
||||
body = JSON.stringify(toSend)
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt)
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount)
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6)
|
||||
var maxDelay = 1000 * Math.pow(2, exp)
|
||||
return maxDelay * Math.random()
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var internalData = api.getInternalData(elt)
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, [])
|
||||
sock.binaryType = htmx.config.wsBinaryType
|
||||
return sock
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, 'hx-ws')) {
|
||||
result.push(elt)
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll('[' + attributeName + '], [data-' + attributeName + '], [data-hx-ws], [hx-ws]').forEach(function(node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -66,8 +66,12 @@
|
||||
<div>
|
||||
{{ range $_, $w := $a.Works }}
|
||||
{{- if ne (len $w.Citation.InnerXML ) 0 -}}
|
||||
<div>
|
||||
{{- $w.Citation.InnerXML -}}
|
||||
<div hx-ext="client-side-templates">
|
||||
<div xslt-template="citation-xslt" xslt-onload>
|
||||
<xml>
|
||||
{{- Safe $w.Citation.InnerXML -}}
|
||||
</xml>
|
||||
</div>
|
||||
</div>
|
||||
{{- end -}}
|
||||
{{ end }}
|
||||
@@ -89,3 +93,5 @@
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ template "_xslt_citation" . }}
|
||||
|
||||
15
views/routes/components/_xslt_citation.gohtml
Normal file
15
views/routes/components/_xslt_citation.gohtml
Normal file
@@ -0,0 +1,15 @@
|
||||
<script id="citation-xslt" type="application/xml">
|
||||
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
||||
<xsl:output method="html" indent="yes" />
|
||||
<xsl:template match="title">
|
||||
<em>
|
||||
<xsl:apply-templates />
|
||||
</em>
|
||||
</xsl:template>
|
||||
<xsl:template match="year">
|
||||
<span class="">
|
||||
<xsl:apply-templates />
|
||||
</span>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
</script>
|
||||
@@ -1,34 +1,68 @@
|
||||
import "./site.css";
|
||||
|
||||
function setup() {
|
||||
let templates = document.querySelectorAll('template[simple]');
|
||||
templates.forEach((template) => {
|
||||
let templateId = template.getAttribute('id');
|
||||
let templateContent = template.content;
|
||||
const ATTR_XSLT = "[xslt-onload]";
|
||||
const ATTR_XSLT_TEMPLATE = "xslt-template";
|
||||
const ATTR_XSLT_STATE = "xslt-transformed";
|
||||
|
||||
customElements.define(templateId, class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.appendChild(templateContent.cloneNode(true));
|
||||
this.slots = this.querySelectorAll('slot');
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
let toremove = [];
|
||||
this.slots.forEach((tslot) => {
|
||||
let slotName = tslot.getAttribute('name');
|
||||
let slotContent = this.querySelector(`[slot="${slotName}"]`);
|
||||
if (slotContent) {
|
||||
tslot.replaceWith(slotContent.cloneNode(true));
|
||||
toremove.push(slotContent);
|
||||
}
|
||||
});
|
||||
toremove.forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
function setup_xslt(evt) {
|
||||
let els = document.querySelectorAll(ATTR_XSLT);
|
||||
for (let element of els) {
|
||||
if (element.getAttribute(ATTR_XSLT_STATE) === "true") {
|
||||
continue;
|
||||
}
|
||||
let templateId = element.getAttribute(ATTR_XSLT_TEMPLATE);
|
||||
let template = htmx.find("#" + templateId);
|
||||
if (template) {
|
||||
let content = template.innerHTML
|
||||
? new DOMParser().parseFromString(template.innerHTML, "application/xml")
|
||||
: template.contentDocument;
|
||||
let processor = new XSLTProcessor();
|
||||
processor.importStylesheet(content);
|
||||
let data = new DOMParser().parseFromString(element.innerHTML, "application/xml");
|
||||
let frag = processor.transformToFragment(data, document);
|
||||
let s = new XMLSerializer().serializeToString(frag);
|
||||
element.innerHTML = s;
|
||||
element.setAttribute(ATTR_XSLT_STATE, true);
|
||||
} else {
|
||||
throw new Error("Unknown XSLT template: " + templateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { setup }
|
||||
function setup() {
|
||||
setup_xslt(null);
|
||||
htmx.on("htmx:load", setup_xslt);
|
||||
let templates = document.querySelectorAll("template[simple]");
|
||||
templates.forEach((template) => {
|
||||
let templateId = template.getAttribute("id");
|
||||
let templateContent = template.content;
|
||||
|
||||
customElements.define(
|
||||
templateId,
|
||||
class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.appendChild(templateContent.cloneNode(true));
|
||||
this.slots = this.querySelectorAll("slot");
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
let toremove = [];
|
||||
this.slots.forEach((tslot) => {
|
||||
let slotName = tslot.getAttribute("name");
|
||||
let slotContent = this.querySelector(`[slot="${slotName}"]`);
|
||||
if (slotContent) {
|
||||
tslot.replaceWith(slotContent.cloneNode(true));
|
||||
toremove.push(slotContent);
|
||||
}
|
||||
});
|
||||
toremove.forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export { setup };
|
||||
|
||||
Reference in New Issue
Block a user