DOM
object
%charts
/phlo/resources/DOM/charts.phlo
static
charts :: spark ($values, $color = '#888', $w = 240, $h = 48, $label = null):string
line 10
Genereert een SVG-sparkline-grafiek op basis van de opgegeven waarden, waarbij de benodigde punten en het gebied voor weergave worden berekend.
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$min = min($values)
$range = max(0.0001, $max - $min)
$count = count($values)
$step = $count > 1 ? $w / ($count - 1) : 0
$points = []
foreach ($values AS $i => $v){
$x = round($i * $step, 1)
$y = round($h - (($v - $min) / $range) * ($h - 6) - 3, 1)
$points[] = "$x,$y"
}
$area = array_merge($points, [round($w, 1).comma.$h, '0,'.$h])
$id = 'g'.substr(md5(implode(comma, $points)), 0, 6)
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px"><defs><linearGradient id="'.$id.'" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="'.$color.'" stop-opacity=".4"/><stop offset="100%" stop-color="'.$color.'" stop-opacity="0"/></linearGradient></defs><polygon points="'.implode(space, $area).'" fill="url(#'.$id.')"/><polyline points="'.implode(space, $points).'" fill="none" stroke="'.$color.'" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/></svg>'static
charts :: bars ($values, $color = '#888', $w = 240, $h = 48, $label = null):string
line 30
Genereert een SVG-balkdiagram op basis van de opgegeven waarden, waarbij de hoogte en positie van elke balk ten opzichte van de maximale waarde worden berekend.
$values = array_values(array_map('floatval', (array)$values))
if (!$values) return void
$max = max($values) ?: 1
$count = count($values)
$gap = 2
$bw = max(1, ($w - ($count - 1) * $gap) / $count)
$out = void
foreach ($values AS $i => $v){
$bh = round(($v / $max) * ($h - 4), 1)
$x = round($i * ($bw + $gap), 1)
$y = round($h - $bh, 1)
$out .= '<rect x="'.$x.'" y="'.$y.'" width="'.round($bw, 1).'" height="'.max(0.5, $bh).'" fill="'.$color.'" rx="1.5"/>'
}
$aria = $label !== null ? ' role="img" aria-label="'.esc($label).'"' : void
return '<svg viewBox="0 0 '.$w.' '.$h.'"'.$aria.' xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" style="display:block;width:100%;height:'.$h.'px">'.$out.'</svg>'static
charts :: donut ($parts, $colors = null, $size = 120):string
line 48
Genereert een SVG donutgrafiek op basis van de opgegeven delen, waarbij de verhoudingen en kleuren voor elk segment worden berekend.
$parts = array_filter(array_map('floatval', (array)$parts))
$total = array_sum($parts)
if (!$total) return void
$colors ??= ['#FFC76B', '#6CC0FF', '#9D7BFF', '#6BE39E', '#FF8A5C', '#FF6B6B']
$cx = $size / 2
$cy = $size / 2
$r = $size * 0.4
$circumference = 2 * M_PI * $r
$out = void
$rotate = -90
$i = 0
foreach ($parts AS $value){
$len = ($value / $total) * $circumference
$color = $colors[$i % count($colors)]
$out .= '<circle cx="'.$cx.'" cy="'.$cy.'" r="'.$r.'" fill="none" stroke="'.$color.'" stroke-width="'.($size * 0.18).'" stroke-dasharray="'.$len.space.$circumference.'" transform="rotate('.$rotate.space.$cx.space.$cy.')"/>'
$rotate += ($value / $total) * 360
$i++
}
return '<svg viewBox="0 0 '.$size.' '.$size.'" xmlns="http://www.w3.org/2000/svg" style="display:block;width:'.$size.'px;height:'.$size.'px">'.$out.'<circle cx="'.$cx.'" cy="'.$cy.'" r="'.($r - $size * 0.09).'" fill="rgba(255,255,255,.04)"/></svg>'object
%cookiewall
/phlo/resources/DOM/cookiewall.phlo
static
cookiewall :: __handle
line 11
Deze methode beheert de cookie wall-functionaliteit en regelt de gebruikersconsent voor cookies.
nullprop
%cookiewall -> choice
line 13
Haal de waarde van 'cookieChoice' op uit het %cookies-object, en retourneer null als deze niet is ingesteld.
%cookies->cookieChoice ?? nullmethod
%cookiewall -> hasChosen:bool
line 14
Controleert of er een keuze is gemaakt door te verifiëren dat de keuze niet null is.
$this->choice !== nullmethod
%cookiewall -> canTrack:bool
line 15
Bepaalt of tracking is toegestaan op basis van de keuze van de gebruiker, specifiek als deze is ingesteld op 'all'.
$this->choice === 'all'method
%cookiewall -> canAnalytics:bool
line 16
Controleert of de huidige keuze is ingesteld op 'all' om te bepalen of analytics kan worden ingeschakeld.
$this->choice === 'all'prop
%cookiewall -> translate:bool
line 18
Controleert of de functie 'en' bestaat in de huidige scope.
function_exists('en')prop
%cookiewall -> labels:array
line 19
Definieert een set labels voor een cookie-toestemmingsmuur, inclusief de titel van de regio, de tekst van de body en de knoppenlabels.
arr(
region: 'Cookie choice',
body: 'We use essential cookies to make this site work. With your permission we also use analytics to improve the site.',
essential: 'Essential only',
accept: 'Accept',
)method
%cookiewall -> label ($key):string
line 25
Haal het label op dat is gekoppeld aan de opgegeven sleutel, en vertaal het indien vertaling is ingeschakeld.
$this->translate ? en($this->labels[$key]) : $this->labels[$key]route
route async POST cookiewall accept all
line 27
Definieert een route die een cookie instelt waarin staat dat de gebruiker alle cookies heeft geaccepteerd en verwijdert de cookiewall view asynchroon.
%cookies->objSet('cookieChoice', 'all', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')route
route async POST cookiewall accept essential
line 32
Deze route stelt een cookie in met de naam 'cookieChoice' met de waarde 'essential' die over een jaar verloopt en past vervolgens een verwijdering toe van het element met de ID 'cookiewall'.
%cookies->objSet('cookieChoice', 'essential', ['expires' => time() + 60 * 60 * 24 * 365, 'httponly' => false])
apply(remove: '#cookiewall')view
%cookiewall -> banner
line 37
Toont een cookie-toestemmingsmuur die gebruikers vraagt om essentiële of alle cookies te accepteren als ze nog geen keuze hebben gemaakt.
<if !$this->hasChosen()>
<div#cookiewall role=region aria-label="{{ $this->label('region') }}">
<p>{{ $this->label('body') }}</p>
<div.actions>
<form.async method=post action=/cookiewall/accept/essential>
<button.ghost type=submit>{{ $this->label('essential') }}</button>
</form>
<form.async method=post action=/cookiewall/accept/all>
<button.primary type=submit>{{ $this->label('accept') }}</button>
</form>
</div>
</div>
</if>view
style
line 52
Definieert de CSS-stijlen voor de cookiewall-component, inclusief lay-out, kleuren en responsief ontwerp.
#cookiewall {
background: #1a1a1a
border-radius: 8px
bottom: 16px
box-shadow: 0 8px 32px #0004
color: #fff
font-size: 13px
left: 16px
line-height: 1.5
max-width: 360px
padding: 14px 16px
position: fixed
right: 16px
z-index: 9999
@media(min-width: 600px): right: auto
p: margin: 0 0 10px
.actions {
display: flex
gap: 8px
justify-content: flex-end
}
button {
border-radius: 4px
border: 0
cursor: pointer
font-size: 12px
padding: 6px 12px
}
button.ghost {
background: #444
color: #fff
}
button.primary {
background: #fff
color: #1a1a1a
font-weight: 600
}
}object
%CSS_fixes
/phlo/resources/DOM/CSS.fixes.phlo
view
style
line 10
*, ::before, ::after: box-sizing: border-box
a, area, button, input, label, select, summary, textarea, [tabindex]: touch-action: manipulation
button:focus, input:focus, select:focus, textarea:focus, [contenteditable]:focus: outline: 0
input::-webkit-outer-spin-button, input::-webkit-inner-spin-button: -webkit-appearance: none
input[type="number"]: -moz-appearance: textfield
table: border-collapse: collapse
[hidden]: display: none !important
::-ms-expand: display: noneobject
%CSS_var
/phlo/resources/DOM/CSS.var.phlo
view
script
line 10
Definieert een proxy voor het openen en wijzigen van CSS-aangepaste eigenschappen (variabelen) op het root-element van het document, waardoor dynamische updates en het ophalen van hun waarden mogelijk zijn.
Object.defineProperty(app, 'var', {get(){return new Proxy({}, {get(_, key){return getComputedStyle(document.documentElement).getPropertyValue(`--${key}`).trim()}, set(_, key, value){ return document.documentElement.style.setProperty(`--${key}`, value)}})}, configurable: true})
app.mod.setvar = (key, value) => app.var[key] = valueobject
%datatags
/phlo/resources/DOM/datatags.phlo
view
script
line 11
Behandelt klikgebeurtenissen op elementen met data-attributen voor HTTP-methoden, voorkomt standaardacties en voert de bijbehorende methode uit met het opgegeven pad en gegevens.
on('click', '[data-get], [data-post], [data-put], [data-patch], [data-delete]', (el, e) => {
if (el.dataset.confirm) return
e.preventDefault()
let method, path, data = null
if ((path = el.dataset.get) !== undefined) method = 'get'
else if ((path = el.dataset.delete) !== undefined) method = 'delete'
else [data = {}, Object.keys(el.dataset).forEach(key => key === 'post' || key === 'put' || key === 'patch' ? [method = key, path = el.dataset[key]] : data[key] = el.dataset[key])]
app[method](path, data)
})object
%dialog
/phlo/resources/DOM/dialog.phlo
view
script
line 11
Maakt en beheert dialoogvensters voor waarschuwingen, bevestigingen en prompts in een Phlo-applicatie, waardoor gebruikersinteractie via modale dialoogvensters mogelijk is.
window.alert = app.mod.alert = msg => phlo.dialog('alert', msg)
window.confirm = msg => phlo.dialog('confirm', msg)
window.prompt = (msg, defaultValue) => phlo.dialog('prompt', msg, defaultValue)
phlo.dialog = async (type, message, defaultValue = '') => new Promise(resolve => {
app.mod.append('body', '<dialog id="phloDialog" class="phlo-dialog" role="dialog" aria-modal="true">\n<form method="dialog">\n<p class="phlo-dialog__message"></p>\n' + (type === 'prompt' ? '<input class="phlo-dialog__input" name="value">' : '') + '\n<menu class="phlo-dialog__actions">\n<button value="1" autofocus>OK</button>\n' + (type !== 'alert' ? '<button value="0">Cancel</button>' : '') + '\n</menu>\n</form>\n</dialog>')
const dialog = obj('#phloDialog')
const messageEl = dialog.querySelector('.phlo-dialog__message')
messageEl && (messageEl.textContent = String(message ?? ''))
if (type === 'prompt') dialog.querySelector('input').value = String(defaultValue ?? '')
dialog.showModal()
dialog.addEventListener('close', () => {
const value = dialog.returnValue
const input = dialog.querySelector('input')
dialog.remove()
if (type === 'alert') return resolve()
if (type === 'confirm') return resolve(value === '1')
if (type === 'prompt') return resolve(value === '1' ? input.value : null)
})
})
on('click', '[data-confirm]', async (el, e) => {
e.preventDefault()
if (!await window.confirm(el.dataset.confirm)) return
delete el.dataset.confirm
app.update()
el.click()
})object
%exists
/phlo/resources/DOM/exists.phlo
view
script
line 11
Deze functie houdt elementen en hun bijbehorende callbacks bij en voert de callbacks uit voor elementen die bestaan maar nog niet eerder zijn geregistreerd.
phlo.exist = []
phlo.existing = new WeakMap
const onExist = (els, cb) => phlo.exist.push({els, cb})
app.updates.push(() => {
const existing = []
phlo.exist.forEach(item => objects(item.els).forEach(el => phlo.existing.has(el) || existing.push({el, cb: item.cb})))
existing.forEach(item => [phlo.existing.has(item.el) || phlo.existing.set(item.el, 'exist'), item.cb(item.el)])
})object
%ffmpeg
/phlo/resources/DOM/ffmpeg.phlo
view
script
line 17
ffmpeg-wasm brought to the browser DOM.
class Ffmpeg {
constructor(opts = {}){
this.corePath = opts.corePath || '/ffmpeg/'
this.mp4boxURL = opts.mp4boxURL || '/mp4box.js'
this.muxerURL = opts.muxerURL || '/mp4-muxer.js'
this.log = opts.log || (() => {})
this.ffmpeg = null
this.mode = null
this.scripts = {}
this.demuxKey = null
this.demuxVal = null
}
loadScript(src){
if (this.scripts[src]) return this.scripts[src]
this.scripts[src] = new Promise((resolve, reject) => {
const el = document.createElement('script')
el.src = src
const nonce = document.querySelector('meta[name="nonce"]')
if (nonce) el.nonce = nonce.content
el.onload = () => resolve()
el.onerror = () => {
delete this.scripts[src]
reject(new Error('ffmpeg: failed to load ' + src))
}
document.head.appendChild(el)
})
return this.scripts[src]
}
async frameSource(src, opts = {}){
const from = opts.from || 0
const to = opts.to || 0
const mp4boxURL = opts.mp4boxURL || this.mp4boxURL
const log = opts.log || this.log
const isFile = (typeof File !== 'undefined') && src instanceof File
const seek = () => this.seekSource(isFile ? URL.createObjectURL(src) : src, isFile)
if (!('VideoDecoder' in window)) return seek()
let wc = null
try {
await this.loadScript(mp4boxURL)
const size = await this.sourceSize(src)
const key = isFile ? src.name + ':' + src.size + ':' + src.lastModified : src
let demuxed
if (this.demuxKey === key) demuxed = this.demuxVal
else {
demuxed = await this.demux(src, size)
this.demuxKey = key
this.demuxVal = demuxed
}
const support = await VideoDecoder.isConfigSupported(demuxed.config)
if (!support.supported){
log('[ffmpeg] codec ' + demuxed.config.codec + ' not decodable, using seek')
return seek()
}
log('[ffmpeg] WebCodecs ' + demuxed.config.codec + ', ' + demuxed.samples.length + ' frames, ' + Math.round(size / 1048576) + ' MiB streamed')
const build = async acceleration => {
const config = acceleration ? Object.assign({}, demuxed.config, {hardwareAcceleration: acceleration}) : demuxed.config
const source = this.webCodecsSource(src, size, config, demuxed.samples, from, to)
try {
if (!await source.getFrame(from)) throw new Error('decoder produced no frames')
}
catch (err){
source.close()
throw err
}
return source
}
try {
wc = await build()
}
catch (err){
log('[ffmpeg] hardware decode failed (' + (err && err.message ? err.message : err) + '), retrying software')
wc = await build('prefer-software')
}
return wc
}
catch (err){
if (wc) try { wc.close() } catch (e){}
log('[ffmpeg] WebCodecs unavailable (' + (err && err.message ? err.message : err) + '), using seek')
return seek()
}
}
async sourceSize(src){
if ((typeof File !== 'undefined') && src instanceof File) return src.size
const res = await fetch(src, {method: 'HEAD'})
const length = res.headers.get('content-length')
if (!length) throw new Error('source size unknown')
return parseInt(length, 10)
}
async readRange(src, offset, length){
if ((typeof File !== 'undefined') && src instanceof File) return new Uint8Array(await src.slice(offset, offset + length).arrayBuffer())
const res = await fetch(src, {headers: {Range: 'bytes=' + offset + '-' + (offset + length - 1)}})
if (res.status !== 206) throw new Error('no range support: ' + res.status)
return new Uint8Array(await res.arrayBuffer())
}
async demux(src, size){
const mp4 = MP4Box.createFile()
let info = null
let error = null
mp4.onReady = i => info = i
mp4.onError = e => error = e
const step = 1 << 20
let next = 0
while (!info && !error && next < size){
const bytes = await this.readRange(src, next, Math.min(step, size - next))
if (!bytes.length) break
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
buffer.fileStart = next
const parsed = mp4.appendBuffer(buffer)
if (parsed == null || parsed <= next) break
next = parsed
}
if (error) throw new Error('demux error: ' + error)
const track = info && info.videoTracks && info.videoTracks[0]
if (!track) throw new Error('no video track')
const trak = mp4.getTrackById(track.id)
let description = null
for (const entry of trak.mdia.minf.stbl.stsd.entries){
const box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C
if (box){
const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN)
box.write(stream)
description = new Uint8Array(stream.buffer, 8)
break
}
}
const samples = (trak.samples || []).map(s => ({offset: s.offset, size: s.size, cts: s.cts, duration: s.duration, timescale: s.timescale, key: !!s.is_sync}))
if (!samples.length) throw new Error('demux produced no samples')
return {config: {codec: track.codec, codedWidth: track.video.width, codedHeight: track.video.height, description}, samples}
}
webCodecsSource(src, size, config, samples, startT, endT){
const decoded = []
let error = null
let waiter = null
let target = -Infinity
const signal = () => {
if (!waiter) return
const w = waiter
waiter = null
w()
}
const prune = () => {
let keep = 0
for (let i = 0; i < decoded.length; i++){
if (decoded[i].timestamp <= target + 1000) keep = i
else break
}
for (let i = 0; i < keep; i++) decoded[i].close()
if (keep) decoded.splice(0, keep)
}
const decoder = new VideoDecoder({
output: f => {
decoded.push(f)
prune()
signal()
},
error: e => {
error = e
signal()
},
})
decoder.configure(config)
const seconds = s => s.cts / s.timescale
let fed = 0
let last = samples.length - 1
for (let i = 0; i < samples.length; i++) if (samples[i].key && seconds(samples[i]) <= startT) fed = i
if (endT > startT){
for (let i = samples.length - 1; i >= fed; i--) if (seconds(samples[i]) <= endT){
last = Math.min(samples.length - 1, i + 2)
break
}
}
const blockSize = 8 << 20
const self = this
let block = null
const ensure = async (offset, need) => {
if (block && offset >= block.start && offset + need <= block.end) return
const end = Math.min(size, Math.max(offset + need, offset + blockSize))
block = {start: offset, end, bytes: await self.readRange(src, offset, end - offset)}
}
const nextChunk = async () => {
const s = samples[fed++]
await ensure(s.offset, s.size)
const at = s.offset - block.start
return new EncodedVideoChunk({
type: s.key ? 'key' : 'delta',
timestamp: Math.round(s.cts * 1e6 / s.timescale),
duration: Math.round((s.duration || 0) * 1e6 / s.timescale),
data: block.bytes.subarray(at, at + s.size),
})
}
let flushed = false
const pump = async tMicros => {
target = tMicros
while (!error){
prune()
if (decoded.length && decoded[decoded.length - 1].timestamp > tMicros) return
if (fed <= last){
while (fed <= last && decoder.decodeQueueSize < 4 && decoded.length < 4) decoder.decode(await nextChunk())
await new Promise(r => {
waiter = r
setTimeout(signal, 100)
})
}
else {
if (!flushed){
flushed = true
await decoder.flush().catch(e => error = e)
}
return
}
}
}
return {
mode: 'webcodecs',
async getFrame(t){
const tM = Math.round(t * 1e6)
await Promise.race([pump(tM), new Promise((res, rej) => setTimeout(() => rej(new Error('WebCodecs decode timeout')), 20000))])
if (error) throw error
let idx = 0
for (let i = 0; i < decoded.length; i++){
if (decoded[i].timestamp <= tM + 1000) idx = i
else break
}
for (let i = 0; i < idx; i++) decoded[i].close()
decoded.splice(0, idx)
return decoded[0] || null
},
close(){
block = null
for (const f of decoded) f.close()
decoded.length = 0
try {
decoder.close()
}
catch (e){}
},
}
}
seekTo(rv, time){
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('seek timeout')), 20000)
rv.addEventListener('seeked', function once(){
rv.removeEventListener('seeked', once)
clearTimeout(timer)
resolve()
})
rv.currentTime = time
})
}
async seekSource(srcUrl, revoke){
const rv = document.createElement('video')
rv.muted = true
rv.preload = 'auto'
rv.src = srcUrl
const self = this
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('video load timeout')), 20000)
rv.addEventListener('loadedmetadata', () => {
clearTimeout(timer)
resolve()
}, {once: true})
rv.addEventListener('error', () => {
clearTimeout(timer)
reject(new Error('Could not load video for rendering'))
}, {once: true})
})
return {
mode: 'seek',
async getFrame(t){
await self.seekTo(rv, Math.min(t, (rv.duration || t + 1) - 0.001))
return rv
},
close(){
rv.removeAttribute('src')
rv.load()
if (revoke) URL.revokeObjectURL(srcUrl)
},
}
}
drop(){
if (!this.ffmpeg) return
try {
this.ffmpeg.terminate()
}
catch (e){}
this.ffmpeg = null
}
async load(log){
log = log || this.log
await this.loadScript(this.corePath + 'ffmpeg.js')
const mode = (window.crossOriginIsolated && !window.ffmpegForceST) ? 'mt' : 'st'
if (this.ffmpeg && this.ffmpeg.loaded && this.mode === mode) return this.ffmpeg
this.drop()
log('[ffmpeg] loading core: ' + mode)
this.ffmpeg = new FFmpegWASM.FFmpeg()
this.ffmpeg.on('log', e => log('[ffmpeg] ' + e.message))
this.mode = mode
const base = new URL(this.corePath, location.origin).href
await this.ffmpeg.load(mode === 'mt'
? {coreURL: base + 'ffmpeg-core.js', wasmURL: base + 'ffmpeg-core.wasm', workerURL: base + 'ffmpeg-core.worker.js'}
: {coreURL: base + 'ffmpeg-core-st.js', wasmURL: base + 'ffmpeg-core-st.wasm'})
return this.ffmpeg
}
async exec(args, log){
log = log || this.log
const ff = this.ffmpeg
log('[ffmpeg] $ ffmpeg ' + args.join(' '))
let beat = performance.now()
const bump = () => beat = performance.now()
ff.on('log', bump)
ff.on('progress', bump)
const watchdog = setInterval(() => {
if (performance.now() - beat > 25000) ff.terminate()
}, 4000)
try {
await ff.exec(args)
}
finally {
clearInterval(watchdog)
ff.off('log', bump)
ff.off('progress', bump)
}
}
async withFallback(fn, onProgress, log){
log = log || this.log
try {
return await fn()
}
catch (err){
if (this.mode !== 'mt') throw err
log('[ffmpeg] multithreaded stalled (' + (err && err.message ? err.message : err) + '); retrying single-threaded')
window.ffmpegForceST = true
this.drop()
if (onProgress) onProgress('Multithreaded encoder stalled; retrying single-threaded...', 3)
return fn()
}
}
async run(opts){
const log = opts.log || this.log
return this.withFallback(async () => {
const ff = await this.load(log)
const inName = opts.inputName || 'input'
const outName = opts.output || 'output'
const cleanups = []
if (opts.input != null){
const src = opts.input
const bytes = src instanceof Uint8Array ? src
: (typeof Blob !== 'undefined' && src instanceof Blob ? new Uint8Array(await src.arrayBuffer())
: new Uint8Array(await fetch(src).then(r => r.arrayBuffer())))
await ff.writeFile(inName, bytes)
cleanups.push(() => ff.deleteFile(inName).catch(() => {}))
}
try {
await this.exec(opts.args, log)
const out = await ff.readFile(outName)
cleanups.push(() => ff.deleteFile(outName).catch(() => {}))
return new Blob([out.buffer], {type: opts.type || 'video/mp4'})
}
finally {
for (const c of cleanups) await c()
}
}, opts.onProgress, log)
}
async transcode(input, opts = {}){
const to = opts.to || 'mp4'
const inName = 'tc-in'
const outName = 'tc-out.' + to
const args = ['-i', inName]
if (opts.args) args.push(...opts.args)
else args.push('-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(opts.crf || 20), '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart')
args.push(outName)
return this.run({input, inputName: inName, output: outName, args, type: opts.type || 'video/' + to, log: opts.log, onProgress: opts.onProgress})
}
async encode(opts){
this.demuxKey = null
this.demuxVal = null
const log = opts.log || this.log
if ('VideoEncoder' in window && !window.ffmpegForceEncoder){
try {
return await this.encodeWebCodecs(opts)
}
catch (err){
log('[ffmpeg] hardware encode failed (' + (err && err.message ? err.message : err) + '), retrying software')
try {
return await this.encodeWebCodecs(Object.assign({}, opts, {acceleration: 'prefer-software'}))
}
catch (err2){
log('[ffmpeg] WebCodecs encode failed (' + (err2 && err2.message ? err2.message : err2) + '), using ffmpeg')
}
}
}
return this.withFallback(() => this.pass(opts), opts.onProgress, log)
}
async pickVideoCodec(width, height, fps, bitrate, acceleration){
for (const codec of ['avc1.640028', 'avc1.4d0028', 'avc1.42001f']){
const config = {codec, width, height, bitrate, framerate: fps}
if (acceleration) config.hardwareAcceleration = acceleration
try {
const support = await VideoEncoder.isConfigSupported(config)
if (support.supported) return codec
}
catch (e){}
}
return null
}
async encodeWebCodecs(opts){
const log = opts.log || this.log
const fps = opts.fps
const from = opts.from || 0
const to = opts.to
const onProgress = opts.onProgress || (() => {})
const audio = opts.audio || []
const W = Math.round(opts.width) - (Math.round(opts.width) % 2)
const H = Math.round(opts.height) - (Math.round(opts.height) % 2)
const total = Math.max(1, Math.round((to - from) * fps))
const bitrate = opts.bitrate || Math.round(W * H * fps * 0.15)
await this.loadScript(this.muxerURL)
const codec = await this.pickVideoCodec(W, H, fps, bitrate, opts.acceleration)
if (!codec) throw new Error('no supported h264 encoder configuration')
log('[ffmpeg] WebCodecs encode ' + W + 'x' + H + ' @' + fps + 'fps, ' + total + ' frames, ' + codec + ', ' + Math.round(bitrate / 1000) + ' kbps' + (opts.acceleration ? ', ' + opts.acceleration : ''))
const target = new Mp4Muxer.ArrayBufferTarget()
const muxer = new Mp4Muxer.Muxer({target, video: {codec: 'avc', width: W, height: H}, fastStart: 'in-memory'})
let error = null
const encoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: e => error = e,
})
const config = {codec, width: W, height: H, bitrate, framerate: fps, avc: {format: 'avc'}}
if (opts.acceleration) config.hardwareAcceleration = opts.acceleration
encoder.configure(config)
const canvas = opts.canvas || document.createElement('canvas')
canvas.width = W
canvas.height = H
const ctx = canvas.getContext('2d', {willReadFrequently: false})
const keyEvery = Math.max(1, Math.round(fps * 2))
let video
if (opts.beforePass) await opts.beforePass()
try {
for (let i = 0; i < total; i++){
if (error) throw error
await opts.drawFrame(ctx, from + i / fps, W, H)
const frame = new VideoFrame(canvas, {timestamp: Math.round(i * 1e6 / fps), duration: Math.round(1e6 / fps)})
encoder.encode(frame, {keyFrame: i % keyEvery === 0})
frame.close()
while (encoder.encodeQueueSize > 8 && !error) await new Promise(r => setTimeout(r, 0))
onProgress('Rendering frame ' + (i + 1) + '/' + total, 5 + 85 * (i + 1) / total)
}
if (error) throw error
await Promise.race([encoder.flush(), new Promise((res, rej) => setTimeout(() => rej(new Error('encoder flush timeout')), 30000))])
if (error) throw error
muxer.finalize()
video = new Blob([target.buffer], {type: 'video/mp4'})
}
finally {
try {
encoder.close()
}
catch (e){}
if (opts.afterPass) await opts.afterPass()
}
log('[ffmpeg] WebCodecs video ' + video.size + ' bytes')
if (!audio.length) return video
return this.muxAudio(video, audio, onProgress, log)
}
async muxAudio(video, audio, onProgress, log){
log = log || this.log
onProgress('Muxing audio...', 92)
const ff = await this.load(log)
await ff.writeFile('wcv.mp4', new Uint8Array(await video.arrayBuffer()))
let prepared
try {
prepared = await this.prepareAudio(ff, audio)
}
catch (err){
log('[ffmpeg] audio unavailable, keeping silent video: ' + (err && err.message ? err.message : err))
await ff.deleteFile('wcv.mp4').catch(() => {})
return video
}
const args = ['-i', 'wcv.mp4'].concat(prepared.args)
const ins = prepared.inputs
if (ins.length === 1 && ins[0].volume === 1 && !ins[0].delay) args.push('-map', '0:v', '-map', '1:a?', '-c', 'copy', '-shortest', 'out.mp4')
else if (ins.length === 1 && !ins[0].delay) args.push('-map', '0:v', '-map', '1:a?', '-c:v', 'copy', '-filter:a', 'volume=' + ins[0].volume, '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')
else {
const parts = ins.map((a, i) => '[' + (i + 1) + ':a]' + (a.delay ? 'adelay=' + Math.round(a.delay) + ':all=1,' : '') + 'volume=' + a.volume + '[a' + i + ']')
const mix = ins.map((a, i) => '[a' + i + ']').join('') + 'amix=inputs=' + ins.length + ':duration=longest:normalize=0[aout]'
args.push('-filter_complex', parts.join(';') + ';' + mix, '-map', '0:v', '-map', '[aout]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')
}
try {
await this.exec(args, log)
onProgress('Reading result...', 98)
const out = await ff.readFile('out.mp4')
return new Blob([out.buffer], {type: 'video/mp4'})
}
catch (err){
log('[ffmpeg] audio mux failed, keeping silent video: ' + (err && err.message ? err.message : err))
video.silent = true
return video
}
finally {
await ff.deleteFile('wcv.mp4').catch(() => {})
await ff.deleteFile('out.mp4').catch(() => {})
await prepared.cleanup()
}
}
async prepareAudio(ff, audio){
const args = []
const inputs = []
const cleanups = []
let mountIdx = 0
for (const a of audio){
if (!a) continue
let path
if (a.file){
const dir = '/ffaud' + (mountIdx++)
await ff.createDir(dir).catch(() => {})
await ff.mount(FFmpegWASM.FFFSType.WORKERFS, {files: [a.file]}, dir)
path = dir + '/' + a.file.name
cleanups.push(() => ff.unmount(dir).catch(() => {}))
}
else {
const ext = String(a.name || a.url || 'a.mp4').split('?')[0].split('.').pop() || 'mp4'
const name = a.name || ('ffaud' + inputs.length + '.' + ext)
const bytes = a.bytes ? a.bytes : new Uint8Array(await fetch(a.url).then(r => r.arrayBuffer()))
await ff.writeFile(name, bytes)
path = name
cleanups.push(() => ff.deleteFile(name).catch(() => {}))
}
if (a.seek) args.push('-ss', String(a.seek))
args.push('-i', path)
inputs.push({volume: a.volume == null ? 1 : a.volume, delay: a.delay || 0})
}
return {args, inputs, cleanup: async () => { for (const c of cleanups) await c() }}
}
async pass(opts){
const log = opts.log || this.log
const fps = opts.fps
const from = opts.from || 0
const to = opts.to
const drawFrame = opts.drawFrame
const audio = opts.audio || []
const onProgress = opts.onProgress || (() => {})
const quality = opts.quality || 0.9
const crf = opts.crf || 20
const segmentFrames = opts.segmentFrames || 120
const W = Math.round(opts.width) - (Math.round(opts.width) % 2)
const H = Math.round(opts.height) - (Math.round(opts.height) % 2)
const ff = await this.load(log)
const canvas = opts.canvas || document.createElement('canvas')
canvas.width = W
canvas.height = H
const ctx = canvas.getContext('2d', {willReadFrequently: false})
const total = Math.max(1, Math.round((to - from) * fps))
log('[ffmpeg] encode ' + W + 'x' + H + ' @' + fps + 'fps, ' + total + ' frames, ' + from.toFixed(2) + '-' + to.toFixed(2) + 's, mode=' + this.mode)
if (opts.beforePass) await opts.beforePass()
const segments = []
try {
let frame = 0
let segIdx = 0
while (frame < total){
const count = Math.min(segmentFrames, total - frame)
for (let i = 0; i < count; i++){
const t = from + (frame + i) / fps
await drawFrame(ctx, t, W, H)
const blob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', quality))
await ff.writeFile('f' + String(i).padStart(6, '0') + '.jpg', new Uint8Array(await blob.arrayBuffer()))
onProgress('Rendering frame ' + (frame + i + 1) + '/' + total, 5 + 75 * (frame + i + 1) / total)
}
const segName = 'seg' + segIdx + '.mp4'
onProgress('Encoding segment ' + (segIdx + 1) + '...', 5 + 75 * (frame + count) / total)
await this.exec(['-threads', '4', '-framerate', String(fps), '-i', 'f%06d.jpg', '-frames:v', String(count), '-c:v', 'libx264', '-preset', 'veryfast', '-crf', String(crf), '-pix_fmt', 'yuv420p', segName], log)
for (let i = 0; i < count; i++) await ff.deleteFile('f' + String(i).padStart(6, '0') + '.jpg').catch(() => {})
segments.push(segName)
frame += count
segIdx++
}
}
finally {
if (opts.afterPass) await opts.afterPass()
}
onProgress('Muxing audio...', 85)
await ff.writeFile('list.txt', new TextEncoder().encode(segments.map(s => "file '" + s + "'").join('\n') + '\n'))
const args = ['-f', 'concat', '-safe', '0', '-i', 'list.txt']
let prepared = {args: [], inputs: [], cleanup: async () => {}}
if (audio.length){
try {
prepared = await this.prepareAudio(ff, audio)
}
catch (err){
log('[ffmpeg] audio unavailable, rendering silent: ' + (err && err.message ? err.message : err))
}
}
args.push(...prepared.args)
const ins = prepared.inputs
if (!ins.length) args.push('-map', '0:v', '-an', '-c:v', 'copy', 'out.mp4')
else if (ins.length === 1 && !ins[0].delay){
args.push('-map', '0:v', '-map', '1:a?', '-c:v', 'copy')
if (ins[0].volume !== 1) args.push('-filter:a', 'volume=' + ins[0].volume)
args.push('-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')
}
else {
const parts = ins.map((a, i) => '[' + (i + 1) + ':a]' + (a.delay ? 'adelay=' + Math.round(a.delay) + ':all=1,' : '') + 'volume=' + a.volume + '[a' + i + ']')
const mix = ins.map((a, i) => '[a' + i + ']').join('') + 'amix=inputs=' + ins.length + ':duration=longest:normalize=0[aout]'
args.push('-filter_complex', parts.join(';') + ';' + mix, '-map', '0:v', '-map', '[aout]', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', 'out.mp4')
}
await this.exec(args, log)
onProgress('Reading result...', 96)
const out = await ff.readFile('out.mp4')
log('[ffmpeg] output ' + out.length + ' bytes (' + segments.length + ' segments)')
for (const s of segments) await ff.deleteFile(s).catch(() => {})
await ff.deleteFile('list.txt').catch(() => {})
await ff.deleteFile('out.mp4').catch(() => {})
await prepared.cleanup()
return new Blob([out.buffer], {type: 'video/mp4'})
}
}
const ffmpeg = new Ffmpeg()object
%form
/phlo/resources/DOM/form.phlo
view
script
line 11
Behandelt invoergebeurtenissen voor formelementen, werkt hun attributen bij op basis van gebruikersinteractie en dient het formulier asynchroon in met de opgegeven methode.
on('input change', 'input, select, textarea', input => {
if (input.tagName === 'SELECT') input.querySelectorAll('option').forEach((option, index) => option.selected ? option.setAttribute('selected', '') : option.removeAttribute('selected'))
if (input.type === 'checkbox') input.checked ? input.setAttribute('checked', '') : input.removeAttribute('checked')
if (input.type === 'text' && input.value !== input.getAttribute('value')) input.setAttribute('value', input.value)
if (input.type === 'textarea' && input.value !== input.innerHTML) input.innerHTML = input.value
phlo.state.replace()
return false
})
on('submit', 'form.async', (form, e) => [e.preventDefault(), app[(form.attributes.method?.value ?? 'GET').toLowerCase()](new URL(form.action).pathname.substr(1), new FormData(form))])object
%image_resizer
/phlo/resources/DOM/image.resizer.phlo
view
script
line 10
Verkleint een afbeeldingsbestand tot opgegeven maximale afmetingen terwijl de beeldverhouding behouden blijft, en retourneert de verkleinde afbeelding als een data-URL via een callbackfunctie.
const imageResizer = (file, maxWidth, maxHeight, cb, quality = .8) => {
const img = new Image
img.onload = () => {
let width = img.width, height = img.height
const aspectRatio = width / height
if (width > maxWidth || height > maxHeight){
if (width > height){
width = maxWidth
height = Math.round(maxWidth / aspectRatio)
}
else {
height = maxHeight
width = Math.round(maxHeight * aspectRatio)
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(img, 0, 0, width, height)
cb(canvas.toDataURL(file.type, quality))
}
img.src = URL.createObjectURL(file)
}object
%keyboard
/phlo/resources/DOM/keyboard.phlo
view
script
line 16
Every event is delegated from the body.
phlo.keyboard = {
layouts: {
qwerty: ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'],
azerty: ['azertyuiop', 'qsdfghjklm', 'wxcvbn'],
qwertz: ['qwertzuiop', 'asdfghjkl', 'yxcvbnm'],
},
digits: '1234567890',
shifted: {',': ';', '.': ':', '-': '_', "'": '"'},
labels: {shift: '↑', back: '←', space: ' ', enter: '↵', close: '×'},
open: null,
caps: false,
layout: el => phlo.keyboard.layouts[el?.dataset.keyboard] ? el.dataset.keyboard : (el?.closest('[data-keyboard-layout]')?.dataset.keyboardLayout || 'qwerty'),
key: (value, label, cls) => `<button class="keyboard__key${cls ? ' ' + cls : ''}" type="button" data-keyboard-key="${value}">${label ?? value}</button>`,
rows(name){
const layout = phlo.keyboard.layouts[name] || phlo.keyboard.layouts.qwerty
const cast = char => phlo.keyboard.caps ? (phlo.keyboard.shifted[char] ?? char.toUpperCase()) : char
const key = phlo.keyboard.key
let html = '<div class="keyboard__row">' + [...phlo.keyboard.digits].map(char => key(char)).join('') + key('back', phlo.keyboard.labels.back, 'keyboard__key--wide') + '</div>'
layout.forEach((row, index) => {
html += '<div class="keyboard__row">'
if (index === 2) html += key('shift', phlo.keyboard.labels.shift, 'keyboard__key--wide' + (phlo.keyboard.caps ? ' keyboard__key--on' : ''))
html += [...row].map(char => key(cast(char))).join('')
if (index === 2) html += key('enter', phlo.keyboard.labels.enter, 'keyboard__key--wide')
html += '</div>'
})
html += '<div class="keyboard__row">' + [key('-'), key(cast("'")), key(' ', phlo.keyboard.labels.space, 'keyboard__key--space'), key(cast(',')), key(cast('.')), key('close', phlo.keyboard.labels.close, 'keyboard__key--close')].join('') + '</div>'
return html
},
host(target){
const root = target?.closest('dialog[open]') || document.body
return obj('[data-keyboard-dock]', root) || root
},
render(){
const target = phlo.keyboard.open
if (!target) return
const host = phlo.keyboard.host(target)
const docked = host !== document.body && host.matches('[data-keyboard-dock]')
if (obj('#keyboard')?.parentElement !== host) obj('#keyboard')?.remove()
obj('#keyboard') || app.mod.append(host, '<div id="keyboard" class="keyboard' + (docked ? ' keyboard--docked' : '') + '" role="group" aria-label="On-screen keyboard"></div>')
app.mod.inner('#keyboard', phlo.keyboard.rows(phlo.keyboard.layout(target)))
},
show(target){
phlo.keyboard.open = target
phlo.keyboard.caps = false
phlo.keyboard.render()
},
hide(){
phlo.keyboard.open = null
obj('#keyboard')?.remove()
},
write(target, insert, back = false){
const start = target.selectionStart ?? target.value.length
const end = target.selectionEnd ?? start
const from = back && start === end ? Math.max(0, start - 1) : start
target.value = target.value.slice(0, from) + insert + target.value.slice(end)
const caret = from + insert.length
target.setSelectionRange?.(caret, caret)
target.dispatchEvent(new Event('input', {bubbles: true}))
},
press(name){
const target = phlo.keyboard.open
if (!target) return
if (!target.isConnected) return phlo.keyboard.hide()
if (name === 'close') return phlo.keyboard.hide()
if (name === 'shift'){
phlo.keyboard.caps = !phlo.keyboard.caps
return phlo.keyboard.render()
}
if (name === 'back') return phlo.keyboard.write(target, '', true)
if (name === 'enter'){
const form = target.closest('form')
phlo.keyboard.hide()
target.dispatchEvent(new Event('change', {bubbles: true}))
return form?.requestSubmit()
}
phlo.keyboard.write(target, name)
if (phlo.keyboard.caps){
phlo.keyboard.caps = false
phlo.keyboard.render()
}
},
}
app.keyboard = {
show: selector => phlo.keyboard.show(obj(selector)),
hide: () => phlo.keyboard.hide(),
}
on('focusin', 'body', (body, e) => {
if (e.target.matches?.('[data-keyboard]')) phlo.keyboard.show(e.target)
})
on('click', 'body', (body, e) => {
const el = e.target.closest?.('[data-keyboard-key]')
if (!el) return
e.preventDefault()
phlo.keyboard.press(el.dataset.keyboardKey)
phlo.keyboard.open?.focus({preventScroll: true})
})
on('pointerdown', 'body', (el, e) => {
if (!phlo.keyboard.open) return
if (e.target.closest('#keyboard') || e.target === phlo.keyboard.open) return
const surface = phlo.keyboard.open.closest('dialog[open]')
if (surface && surface.contains(e.target)) return
phlo.keyboard.hide()
})
on('keydown', 'body', (el, e) => {
if (e.key === 'Escape') phlo.keyboard.hide()
})
on('close', 'dialog', dialog => {
if (phlo.keyboard.open && dialog.contains(phlo.keyboard.open)) phlo.keyboard.hide()
})view
style
line 134
.keyboard {
position: fixed
left: 0
right: 0
bottom: 0
z-index: 1000
display: grid
gap: var(--keyboard-gap, .3rem)
padding: var(--keyboard-pad, .5rem)
background: var(--keyboard-bg, #1c2029)
color: var(--keyboard-color, #f2f2f2)
box-shadow: 0 -2px 12px #0006
touch-action: manipulation
user-select: none
}
.keyboard--docked {
position: static
box-shadow: none
padding: var(--keyboard-dock-pad, 0)
background: var(--keyboard-dock-bg, transparent)
}
.keyboard__row {
display: flex
gap: var(--keyboard-gap, .3rem)
justify-content: center
}
.keyboard__key {
font: inherit
font-size: var(--keyboard-size, 1.1rem)
flex: 1 1 auto
max-width: var(--keyboard-key, 4rem)
padding: var(--keyboard-key-pad, .7rem .2rem)
border: 1px solid var(--keyboard-border, #ffffff26)
border-radius: var(--keyboard-radius, 6px)
background: var(--keyboard-key-bg, #ffffff14)
color: inherit
cursor: pointer
\:active: background: var(--keyboard-key-active, #ffffff2e)
}
.keyboard__key--wide {
max-width: var(--keyboard-wide, 6rem)
}
.keyboard__key--space {
max-width: var(--keyboard-space, 18rem)
}
.keyboard__key--on {
background: var(--keyboard-key-on, #ffffff33)
}object
%link
/phlo/resources/DOM/link.phlo
view
script
line 11
on('click', 'a', (a, e) => {
if (e.ctrlKey || e.shiftKey || e.metaKey || a.target || a.dataset.confirm) return false
const isAsync = a.classList.contains('async')
const [uri, hash] = a.getAttribute('href').split('#')
if (isAsync || hash) e.preventDefault()
phlo.anchor = hash ? `#${hash}` : ''
if (hash && (!uri || uri === location.pathname + location.search)) location.hash = phlo.anchor
else if (isAsync) app.get(uri.substr(1))
})object
%markdown
/phlo/resources/DOM/markdown.phlo
view
script
line 10
Parset Markdown-tekst naar HTML, met ondersteuning voor verschillende functies zoals GitHub Flavored Markdown, blokelementen, inline-elementen en referentielinks.
function parse_markdown(md, opts = {}){
const o = {
gfm: opts.gfm !== false,
breaks: !!opts.breaks,
headerIds: opts.headerIds !== false,
headerPrefix: opts.headerPrefix || '',
smartypants: !!opts.smartypants
}
const unnull = x => (x == null ? '' : String(x))
let src = unnull(md).replace(/\r\n?/g, "\n")
const escHtml = s => s.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]))
const trimEndNL = s => s.replace(/\s+$/,'')
const isBlank = s => /^\s*$/.test(s)
const slugmap = new Map()
const slug = t => {
let s = t.toLowerCase().replace(/<\/?[^>]+>/g, '').replace(/[^\p{L}\p{N}\- _]+/gu, '').trim().replace(/[\s_]+/g, '-')
const base = o.headerPrefix + s
let k = base, i = 1
while (slugmap.has(k)) k = `${base}-${++i}`
slugmap.set(k, true)
return k
}
const smart = s => {
if (!o.smartypants) return s
return s.replace(/---/g, "-").replace(/--/g, "–").replace(/(^|[\s"(\[])(?=')/g, "$1‘").replace(/'/g, "’").replace(/(^|[\s(\[])(?=")/g, "$1“").replace(/"/g, "”").replace(/\.{3}/g, "…")
}
const refs = Object.create(null)
src = src.replace(
/^ {0,3}\[([^\]]+)\]:\s*<?([^\s>]+)>?(?:\s+(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*$/gm,
(_, label, url, t1, t2, t3) => {
const key = label.trim().replace(/\s+/g, ' ').toLowerCase()
if (!refs[key]) refs[key] = { href: url, title: t1 || t2 || t3 || '' }
return ''
}
)
const tokens = []
const lines = src.split("\n")
function takeWhile(start, pred){
let end = start
while (end < lines.length && pred(lines[end], end)) end++
return { start, end }
}
function pushParagraph(buf){
const text = buf.join("\n").trimEnd()
if (text) tokens.push({ type: "paragraph", text })
buf.length = 0
}
function parseBlock(start = 0, end = lines.length){
const para = []
let l = start
while (l < end){
const line = lines[l]
if (isBlank(line)){
pushParagraph(para)
l++
continue
}
let m = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)$/)
if (m){
pushParagraph(para)
const fenceLen = m[1].length
const info = (m[2] || '').trim()
let body = []
l++
while (l < end){
const s = lines[l]
const close = s.match(new RegExp(`^ {0,3}${m[1][0]}{${fenceLen},}\\s*$`))
if (close){
l++
break
}
body.push(s)
l++
}
tokens.push({ type: "code", lang: info.split(/\s+/)[0] || '', text: trimEndNL(body.join("\n")) })
continue
}
if (/^(?: {4}|\t)/.test(line)){
pushParagraph(para)
const { end: j } = takeWhile(l, s => /^(?: {4}|\t)/.test(s) || isBlank(s))
const block = lines.slice(l, j).map(s => s.replace(/^(?: {4}|\t)/, '')).join("\n")
tokens.push({ type: "code", lang: '', text: trimEndNL(block) })
l = j
continue
}
if (/^ {0,3}<(?:!--|\/?(?:html|head|body|pre|script|style|table|thead|tbody|tfoot|tr|td|th|div|p|h[1-6]|blockquote|ul|ol|li|section|article|aside|details|summary|figure|figcaption)\b)/i.test(line)){
pushParagraph(para)
const { end: j } = takeWhile(l, (s, idx) => !(idx > l && isBlank(lines[idx-1]) && isBlank(s)))
const html = lines.slice(l, j).join("\n")
tokens.push({ type: "html", text: html })
l = j
continue
}
if (/^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(line)){
pushParagraph(para)
tokens.push({ type: "hr" })
l++
continue
}
m = line.match(/^ {0,3}(#{1,6})[ \t]*([^#\n]*?)[ \t#]*$/)
if (m){
pushParagraph(para)
tokens.push({ type: "heading", depth: m[1].length, text: m[2].trim() })
l++
continue
}
if (l + 1 < end && /^[^\s].*$/.test(line) && /^ {0,3}(=+|-+)\s*$/.test(lines[l + 1])){
pushParagraph(para)
const depth = lines[l + 1].trim().startsWith("=") ? 1 : 2
tokens.push({ type: "heading", depth, text: line.trim() })
l += 2
continue
}
if (/^ {0,3}>\s?/.test(line)){
pushParagraph(para)
const { end: j } = takeWhile(l, s => /^ {0,3}>\s?/.test(s) || isBlank(s))
const inner = lines.slice(l, j).map(s => s.replace(/^ {0,3}>\s?/, '')).join("\n")
const sub = parse_markdown(inner, { ...o })
tokens.push({ type: "blockquote", html: sub })
l = j
continue
}
m = line.match(/^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/)
if (m){
pushParagraph(para)
const bulletRe = /^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/
const { end: j } = takeWhile(l, (s, idx) =>
bulletRe.test(s) ||
(/^(?: {4}|\t)/.test(s)) ||
(!isBlank(s) && idx > l && !/^(?: {0,3}(?:[*+-]|\d{1,9}[.)])\s+)/.test(s))
)
const block = lines.slice(l, j)
const ordered = /^\d/.test(m[1])
const items = []
let cur = []
for (let k = 0; k < block.length; k++){
const ln = block[k]
const head = ln.match(bulletRe)
if (head){
if (cur.length) items.push(cur), cur = []
cur.push(ln.replace(bulletRe, ''))
} else {
cur.push(ln.replace(/^(?: {4}|\t)/, ''))
}
}
if (cur.length) items.push(cur)
const parsedItems = items.map(linesArr => {
let raw = linesArr.join("\n").replace(/\n\s+$/,'')
let checked = null
if (o.gfm){
const t = raw.match(/^\[([ xX])\][ \t]+/)
if (t){
checked = t[1].toLowerCase() === 'x'
raw = raw.replace(/^\[[ xX]\][ \t]+/, '')
}
}
const html = parse_markdown(raw, o)
return { html, checked }
})
tokens.push({ type: "list", ordered, items: parsedItems })
l = j
continue
}
if (o.gfm){
const hdr = line
const alignLn = lines[l + 1] || ''
if (/\|/.test(hdr) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(alignLn)){
pushParagraph(para)
const aligns = alignLn
.trim().replace(/^(\|)|(\|)$/g,'')
.split("|").map(s => s.trim()).map(s => s.startsWith(":-") && s.endsWith("-:") ? "center" : s.endsWith("-:") ? "right" : s.startsWith(":-") ? "left" : null)
const headerCells = hdr.trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim())
l += 2
const rows = []
while (l < end && /\|/.test(lines[l]) && !isBlank(lines[l])){
rows.push(lines[l].trim().replace(/^(\|)|(\|)$/g,'').split("|").map(s => s.trim()))
l++
}
tokens.push({ type: "table", header: headerCells, aligns, rows })
continue
}
}
para.push(line)
const next = lines[l + 1] || ''
const endPara =
isBlank(next) ||
/^ {0,3}(?:`{3,}|~{3,})/.test(next) ||
/^(?: {4}|\t)/.test(next) ||
/^ {0,3}((?:[*+-])|\d{1,9}[.)])\s+/.test(next) ||
/^ {0,3}(#{1,6})/.test(next) ||
/^ {0,3}>\s?/.test(next) ||
/^ {0,3}(?:-+\s*|-{3,}|_{3,}|\*{3,})\s*$/.test(next) ||
(o.gfm && /\|/.test(next) && /^ {0,3}\|? *:?-+:? *(?:\| *:?-+:? *)*\|? *$/.test(lines[l + 2] || ''))
if (endPara) pushParagraph(para)
l++
}
pushParagraph(para)
}
parseBlock(0, lines.length)
function renderInline(s){
if (!s) return ''
s = s.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1/g, (_, ticks, code) => `<code>${escHtml(code)}</code>`)
s = s.replace(/!\[([^\]]*)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
(_, alt, url, t1, t2, t3) => `<img src="${escHtml(url)}" alt="${escHtml(alt)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>`)
s = s.replace(/!\[([^\]]*)\]\[([^\]]*)\]/g, (_, alt, id) => {
const ref = refs[(id || alt).trim().replace(/\s+/g,' ').toLowerCase()]
return ref ? `<img src="${escHtml(ref.href)}" alt="${escHtml(alt)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>` : _
})
s = s.replace(/\[([^\]]+)\]\(\s*<?([^\s)<>]+)>?\s*(?:(?:"([^"]*)"|'([^']*)'|\(([^)]+)\)))?\s*\)/g,
(_, text, url, t1, t2, t3) => `<a href="${escHtml(url)}"${t1||t2||t3?` title="${escHtml(t1||t2||t3)}"`:''}>${text}</a>`)
s = s.replace(/\[([^\]]+)\]\s*\[([^\]]*)\]/g, (_, text, id) => {
const key = (id || text).trim().replace(/\s+/g,' ').toLowerCase()
const ref = refs[key]
return ref ? `<a href="${escHtml(ref.href)}"${ref.title?` title="${escHtml(ref.title)}"`:''}>${text}</a>` : _
})
s = s.replace(/<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^ <>"']+)>/g, (_, url) => `<a href="${escHtml(url)}">${escHtml(url)}</a>`)
s = s.replace(/<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, (_, mail) => `<a href="mailto:${escHtml(mail)}">${escHtml(mail)}</a>`)
if (o.gfm){
s = s.replace(/(?:(?<=\s)|^)(https?:\/\/[^\s<]+)(?=\s|$)/g, '<a href="$1">$1</a>')
s = s.replace(/(?:(?<=\s)|^)(www\.[^\s<]+)(?=\s|$)/g, '<a href="http://$1">$1</a>')
}
s = s.replace(/\*\*([\s\S]+?)\*\*/g, '<strong>$1</strong>').replace(/__([\s\S]+?)__/g, '<strong>$1</strong>')
s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>').replace(/_([^_\n]+?)_/g, '<em>$1</em>')
if (o.gfm) s = s.replace(/~~([\s\S]+?)~~/g, '<del>$1</del>')
s = s.replace(/ {2,}\n/g, "<br>\n")
if (o.breaks) s = s.replace(/\n/g, "<br>\n")
s = s.replace(/&(?!#?\w+;)/g, "&").replace(/<(?!\/?[A-Za-z][^>]*>)/g, "<")
return smart(s)
}
let out = ''
for (const t of tokens){
switch (t.type){
case "paragraph":
out += `<p>${renderInline(t.text)}</p>\n`
break
case "heading": {
const text = renderInline(t.text)
const id = o.headerIds ? slug(text.replace(/<[^>]+>/g, '')) : null
out += id ? `<h${t.depth} id="${id}">${text}</h${t.depth}>\n` : `<h${t.depth}>${text}</h${t.depth}>\n`
break
}
case "code": {
const cls = t.lang ? ` class="language-${escHtml(t.lang)}"` : ''
out += `<pre><code${cls}>${escHtml(t.text)}</code></pre>\n`
break
}
case "blockquote":
out += `<blockquote>\n${t.html.trim()}\n</blockquote>\n`
break
case "list": {
const tag = t.ordered ? "ol" : "ul"
out += `<${tag}>\n`
for (const it of t.items){
const task = it.checked === null ? '' : `<input ${it.checked ? 'checked="" ' : ''}disabled="" type="checkbox"> `
const body = it.html.trim().replace(/^<p>/, task + "<p>")
out += `<li>${body}</li>\n`
}
out += `</${tag}>\n`
break
}
case "table": {
const ths = t.header.map((h, i) => {
const a = t.aligns[i]
return a ? `<th align="${a}">${renderInline(h)}</th>` : `<th>${renderInline(h)}</th>`
}).join("\n")
let body = ''
for (const row of t.rows){
const tds = row.map((cell, i) => {
const a = t.aligns[i]
return a ? `<td align="${a}">${renderInline(cell)}</td>` : `<td>${renderInline(cell)}</td>`
}).join("\n")
body += `<tr>\n${tds}\n</tr>\n`
}
out += `<table>\n<thead>\n<tr>\n${ths}\n</tr>\n</thead>\n` + (body ? `<tbody>\n${body}</tbody>\n` : '') + `</table>\n`
break
}
case "hr":
out += "<hr>\n"
break
case "html":
out += t.text + "\n"
break
}
}
return out.trim()
}object
%numpad
/phlo/resources/DOM/numpad.phlo
view
script
line 17
Keys and focus are handled on the body rather than per element.
phlo.numpad = {
layouts: {
calculator: ['7', '8', '9', '4', '5', '6', '1', '2', '3'],
phone: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
},
labels: {back: '←', clear: 'C', enter: '↵'},
options: el => ({
layout: el.dataset.numpadLayout || 'calculator',
decimal: el.dataset.numpadDecimal ?? ',',
extra: el.dataset.numpadExtra ?? '',
keys: (el.dataset.numpadKeys ?? 'back,clear').split(',').map(key => key.trim()).filter(Boolean),
fresh: el.dataset.numpadFresh !== undefined,
max: parseInt(el.dataset.numpadMax) || 0,
}),
target: el => obj(el.dataset.numpad) || el.closest('form')?.querySelector('input, textarea') || null,
keys(options){
const key = (value, label, cls) => `<button class="numpad__key${cls ? ' ' + cls : ''}" type="button" data-numpad-key="${value}">${label ?? value}</button>`
let html = (phlo.numpad.layouts[options.layout] || phlo.numpad.layouts.calculator).map(value => key(value)).join('')
html += options.extra ? key(options.extra) : ''
html += key('0')
html += options.decimal ? key(options.decimal) : ''
options.keys.forEach(name => html += key(name, phlo.numpad.labels[name] ?? name, 'numpad__key--' + name))
return html
},
build(el){
if (el.dataset.numpadReady) return
el.dataset.numpadReady = '1'
el.classList.add('numpad')
if (!el.querySelector('[data-numpad-key]')) app.mod.inner(el, phlo.numpad.keys(phlo.numpad.options(el)))
},
write(target, value){
target.value = value
target.dispatchEvent(new Event('input', {bubbles: true}))
},
press(target, name, options){
if (!target) return
if (name === 'clear') return phlo.numpad.write(target, '')
if (name === 'back') return phlo.numpad.write(target, target.value.slice(0, -1))
if (name === 'enter'){
const form = target.closest('form')
target.dispatchEvent(new Event('change', {bubbles: true}))
return form?.requestSubmit()
}
let value = target.value
if (options.fresh && !target.dataset.numpadTyped){
value = ''
target.dataset.numpadTyped = '1'
}
if (options.decimal && name === options.decimal && value.includes(options.decimal)) return
if (options.max && (value + name).length > options.max) return
phlo.numpad.write(target, value + name)
},
}
app.numpad = (selector, options = {}) => `<div class="numpad" data-numpad="${selector}"${Object.entries(options).map(([key, value]) => ` data-numpad-${key.toLowerCase()}="${value}"`).join('')}></div>`
onExist('[data-numpad]', el => phlo.numpad.build(el))
on('click', 'body', (body, e) => {
const el = e.target.closest?.('[data-numpad-key]')
const pad = el?.closest('[data-numpad]')
if (!pad) return
const target = phlo.numpad.target(pad)
phlo.numpad.press(target, el.dataset.numpadKey, phlo.numpad.options(pad))
target?.focus({preventScroll: true})
})
on('focusin', 'body', (body, e) => {
if (e.relatedTarget?.closest?.('[data-numpad-key]')) return
if (e.target.dataset?.numpadTyped) delete e.target.dataset.numpadTyped
})view
style
line 91
.numpad {
display: grid
grid-template-columns: repeat(3, 1fr)
gap: var(--numpad-gap, .4rem)
}
.numpad__key {
font: inherit
font-size: var(--numpad-size, 1.2rem)
padding: var(--numpad-pad, .7rem)
border: 1px solid var(--numpad-border, #0002)
border-radius: var(--numpad-radius, 8px)
background: var(--numpad-bg, #0000000a)
color: inherit
cursor: pointer
touch-action: manipulation
user-select: none
\:active: background: var(--numpad-bg-active, #00000018)
}
.numpad__key--enter {
grid-column: span 3
}object
%presentation
/phlo/resources/DOM/presentation.phlo
view
script
line 10
class PresentationPlayer {
static bezier(x1, y1, x2, y2){
const cx = 3 * x1
const bx = 3 * (x2 - x1) - cx
const ax = 1 - cx - bx
const cy = 3 * y1
const by = 3 * (y2 - y1) - cy
const ay = 1 - cy - by
const sampleX = t => ((ax * t + bx) * t + cx) * t
const sampleY = t => ((ay * t + by) * t + cy) * t
return x => {
if (x <= 0) return 0
if (x >= 1) return 1
let t = x
for (let i = 0; i < 8; i++){
const err = sampleX(t) - x
if (Math.abs(err) < 0.001) break
const d = (3 * ax * t + 2 * bx) * t + cx
if (Math.abs(d) < 0.000001) break
t -= err / d
}
return sampleY(Math.min(1, Math.max(0, t)))
}
}
static eases = {
linear: p => p,
ease: this.bezier(0.25, 0.1, 0.25, 1),
easeIn: this.bezier(0.42, 0, 1, 1),
easeOut: this.bezier(0, 0, 0.58, 1),
easeInOut: this.bezier(0.42, 0, 0.58, 1),
zoomB: this.bezier(0.25, 1, 0.5, 1),
tvB: this.bezier(1, 0, 0, 1),
cardsB: this.bezier(0.2, 0, 0.2, 1),
}
static instances = new Set()
static clipRombus(q){
return (ctx, x, y, w, h) => {
const cx = x + w / 2
const cy = y + h / 2
const pts = [[0.5, -0.5], [1.5, 0.5], [0.5, 1.5], [-0.5, 0.5]]
ctx.beginPath()
pts.forEach(([px, py], i) => {
const fx = x + px * w
const fy = y + py * h
const ix = cx + (fx - cx) * q
const iy = cy + (fy - cy) * q
i ? ctx.lineTo(ix, iy) : ctx.moveTo(ix, iy)
})
ctx.closePath()
}
}
static clipCircle(r){
return (ctx, x, y, w, h) => {
ctx.beginPath()
ctx.arc(x + w / 2, y + h / 2, Math.max(0, r * Math.hypot(w, h) / Math.SQRT2), 0, Math.PI * 2)
}
}
static clipInset(l, r){
return (ctx, x, y, w, h) => {
ctx.beginPath()
ctx.rect(x + l * w, y, Math.max(0, (1 - l - r) * w), h)
}
}
static transitions = {
none: null,
fade: {in: 'pp-fade-in', out: 'pp-fade-out', ease: 'ease',
curve: {ease: this.eases.ease,
in: q => ({opacity: q}),
out: q => ({opacity: 1 - q})}},
zoom: {in: 'pp-zoom-in', out: 'pp-zoom-out', ease: 'cubic-bezier(.25,1,.5,1)',
curve: {ease: this.eases.zoomB,
in: q => ({opacity: q, sx: 0.96 + 0.04 * q, sy: 0.96 + 0.04 * q}),
out: q => ({opacity: 1 - q, sx: 1 + 0.05 * q, sy: 1 + 0.05 * q})}},
glide: {in: 'pp-glide-in', out: 'pp-glide-out', ease: 'ease-out',
curve: {ease: this.eases.easeOut,
in: (q, g) => ({tx: -g.W * (1 - q)}),
out: (q, g) => ({opacity: 1 - q, tx: g.W * q})}},
slide: {in: 'pp-slide-in', out: 'pp-slide-out', ease: 'ease-in-out',
curve: {ease: this.eases.easeInOut,
in: (q, g) => ({opacity: q, tx: -20 * g.k * (1 - q)}),
out: (q, g) => ({opacity: 1 - q, tx: 20 * g.k * q})}},
drop: {in: 'pp-drop-in', out: 'pp-drop-out', ease: 'ease',
curve: {ease: this.eases.ease,
in: (q, g) => ({opacity: q, ty: -50 * g.k * (1 - q)}),
out: (q, g) => ({opacity: 1 - q, ty: 50 * g.k * q})}},
skew: {in: 'pp-skew-in', out: 'pp-skew-out', ease: 'ease-in',
curve: {ease: this.eases.easeIn,
in: q => ({opacity: q, skew: -15 * (1 - q) * Math.PI / 180}),
out: q => ({opacity: 1 - q, skew: 15 * q * Math.PI / 180})}},
tilt: {in: 'pp-tilt-in', out: 'pp-tilt-out', ease: 'ease-in',
curve: {ease: this.eases.easeIn,
in: (q, g) => ({opacity: q, rot: -5 * (1 - q) * Math.PI / 180, ty: 20 * g.k * (1 - q)}),
out: (q, g) => ({opacity: 1 - q, rot: 5 * q * Math.PI / 180, ty: -20 * g.k * q})}},
spiral: {in: 'pp-spiral-in', out: 'pp-spiral-out', ease: 'ease-out',
curve: {ease: this.eases.easeOut,
in: q => ({opacity: q, rot: -2 * Math.PI * (1 - q), sx: q, sy: q}),
out: q => ({opacity: 1 - q, rot: 2 * Math.PI * q, sx: 1 - q, sy: 1 - q})}},
ripple: {in: 'pp-ripple-in', out: 'pp-ripple-out', ease: 'ease',
curve: {ease: this.eases.ease,
in: (q, g) => ({opacity: q, sx: 0.5 + 0.5 * q, sy: 0.5 + 0.5 * q, blur: 3 * g.k * (1 - q)}),
out: (q, g) => ({opacity: 1 - q, sx: 1 + 0.5 * q, sy: 1 + 0.5 * q, blur: 3 * g.k * q})}},
curtain: {in: 'pp-curtain-in', out: 'pp-curtain-out', ease: 'ease-in',
curve: {ease: this.eases.easeIn,
in: q => ({opacity: q, sy: Math.max(0.001, q), origin: 'bottom'}),
out: q => ({opacity: 1 - q, sy: Math.max(0.001, 1 - q), origin: 'top'})}},
tv: {in: 'pp-tv-in', out: 'pp-tv-out', ease: 'cubic-bezier(1,0,0,1)',
curve: {ease: this.eases.tvB,
in: q => q < 0.5
? {opacity: 1.6 * q, sx: Math.max(0.001, q * 2), sy: Math.max(0.001, 0.04 * q)}
: {opacity: 0.8 + 0.4 * (q - 0.5) * 2, sx: 1, sy: 0.02 + (q - 0.5) * 2 * 0.98},
out: q => q < 0.5
? {opacity: 1 - 0.4 * q * 2, sx: 1, sy: Math.max(0.001, 1 - q * 2 * 0.98)}
: {opacity: 0.8 - 1.6 * (q - 0.5), sx: Math.max(0.001, 1 - (q - 0.5) * 2), sy: 0.02}}},
flip: {in: 'pp-flip-in', out: 'pp-flip-out', ease: 'ease-in-out',
curve: {ease: this.eases.easeInOut,
in: q => ({opacity: q < 0.5 ? 0 : 1, sx: Math.max(0.001, Math.abs(Math.cos((1 - q) * Math.PI)))}),
out: q => ({opacity: q < 0.5 ? 1 : 0, sx: Math.max(0.001, Math.abs(Math.cos(q * Math.PI)))})}},
cube: {in: 'pp-cube-in', out: 'pp-cube-out', ease: 'ease-in-out', originIn: '0% 50%', originOut: '100% 50%',
curve: {ease: this.eases.easeInOut,
in: q => ({opacity: Math.min(1, q * 2), sx: Math.max(0.001, Math.cos((1 - q) * Math.PI / 2)), origin: 'left'}),
out: q => ({opacity: 1 - q, sx: Math.max(0.001, Math.cos(q * Math.PI / 2)), origin: 'right'})}},
diamond: {in: 'pp-diamond-in', out: 'pp-diamond-out', ease: 'ease',
curve: {ease: this.eases.ease,
in: q => ({clip: PresentationPlayer.clipRombus(q)}),
out: q => ({clip: PresentationPlayer.clipRombus(1 - q)})}},
diaphragm: {in: 'pp-diaphragm-in', out: 'pp-diaphragm-out', ease: 'linear',
curve: {ease: this.eases.linear,
in: q => ({clip: PresentationPlayer.clipCircle(0.9 * q)}),
out: q => ({clip: PresentationPlayer.clipCircle(0.9 * (1 - q))})}},
spotlight: {in: 'pp-spotlight-in', out: 'pp-spotlight-out', ease: 'ease',
curve: {ease: this.eases.ease,
in: q => ({clip: PresentationPlayer.clipCircle(0.75 * q)}),
out: q => ({clip: PresentationPlayer.clipCircle(0.75 * (1 - q))})}},
wipe: {in: 'pp-wipe-in', out: 'pp-wipe-out', ease: 'ease-out',
curve: {ease: this.eases.easeOut,
in: q => ({opacity: Math.min(1, 0.1 + q * 2.2), clip: PresentationPlayer.clipInset(0, 1 - q)}),
out: q => ({opacity: Math.min(1, 1.1 - q), clip: PresentationPlayer.clipInset(q, 0)})}},
glitch: {in: 'pp-glitch-in', out: 'pp-glitch-out', ease: 'linear',
curve: {ease: this.eases.linear,
in: (q, g) => ({opacity: Math.min(1, q * 1.6), bands: q < 0.95 ? 5 * g.k * (1 - q) : 0, seed: q}),
out: (q, g) => ({opacity: Math.max(0, 1 - q * 1.2), bands: q > 0.05 ? 5 * g.k * q : 0, seed: q})}},
cards: {in: 'pp-cards-in', out: 'pp-cards-out', ease: 'cubic-bezier(0.2,0,0.2,1)',
curve: {ease: this.eases.cardsB,
in: (q, g) => ({ty: g.h * (1 - q)}),
out: (q, g) => ({opacity: 1 - q, ty: g.h * q})}},
}
static progress(tt, dur){
if (dur <= 0) return 1
return Math.min(1, Math.max(0, tt / dur))
}
static wave(tt, period){
return (1 - Math.cos(Math.PI * 2 * ((tt % period) / period))) / 2
}
static dirVector(dir){
const map = {left: [1, 0], right: [-1, 0], up: [0, 1], down: [0, -1], tl: [1, 1], tr: [-1, 1], bl: [1, -1], br: [-1, -1]}
return map[dir] || [1, 0]
}
static durings = {
none: null,
push: {css: 'pp-scale', ease: 'linear', amount: 10, dir: '',
vars(a, d){
return {'--pp-s0': String(1 - a / 100)}
},
curve(tt, dur, g, a, d){
const s = 1 - a / 100 * (1 - PresentationPlayer.progress(tt, dur))
return {sx: s, sy: s}
}},
pull: {css: 'pp-scale', ease: 'linear', amount: 10, dir: '',
vars(a, d){
return {'--pp-s0': String(1 + a / 100)}
},
curve(tt, dur, g, a, d){
const s = 1 + a / 100 * (1 - PresentationPlayer.progress(tt, dur))
return {sx: s, sy: s}
}},
pan: {css: 'pp-drift', ease: 'linear', amount: 5, dir: 'left',
vars(a, d){
const [vx, vy] = PresentationPlayer.dirVector(d)
return {'--pp-x0': vx * a + '%', '--pp-y0': vy * a + '%'}
},
curve(tt, dur, g, a, d){
const [vx, vy] = PresentationPlayer.dirVector(d)
const back = 1 - PresentationPlayer.progress(tt, dur)
return {tx: vx * a / 100 * g.w * back, ty: vy * a / 100 * g.h * back}
}},
kenburns: {css: 'pp-kenburns', ease: 'linear', amount: 8, dir: 'tr',
vars(a, d){
const [vx, vy] = PresentationPlayer.dirVector(d)
return {'--pp-s0': String(1 - a / 100), '--pp-x0': vx * a / 2 + '%', '--pp-y0': vy * a / 2 + '%'}
},
curve(tt, dur, g, a, d){
const [vx, vy] = PresentationPlayer.dirVector(d)
const back = 1 - PresentationPlayer.progress(tt, dur)
const s = 1 - a / 100 * back
return {sx: s, sy: s, tx: vx * a / 200 * g.w * back, ty: vy * a / 200 * g.h * back}
}},
float: {css: 'pp-float', ease: 'ease-in-out', amount: 3, dir: '', period: 4,
vars(a, d){
return {'--pp-y1': -a + '%'}
},
curve(tt, dur, g, a, d){
return {ty: -a / 100 * g.h * PresentationPlayer.wave(tt, this.period)}
}},
sway: {css: 'pp-sway', ease: 'ease-in-out', amount: 1.5, dir: '', period: 5,
vars(a, d){
return {'--pp-r0': -a + 'deg', '--pp-r1': a + 'deg'}
},
curve(tt, dur, g, a, d){
return {rot: a * (2 * PresentationPlayer.wave(tt, this.period) - 1) * Math.PI / 180}
}},
pulse: {css: 'pp-pulse', ease: 'ease-in-out', amount: 2, dir: '', period: 2.5,
vars(a, d){
return {'--pp-s1': String(1 + a / 100)}
},
curve(tt, dur, g, a, d){
const s = 1 + a / 100 * PresentationPlayer.wave(tt, this.period)
return {sx: s, sy: s}
}},
}
constructor(root, data, opts = {}){
this.root = root
this.opts = opts
this.mediaBase = opts.mediaBase || data.mediaBase || ''
this.transcript = data.transcript || null
const pres0 = data.presentation || {}
this.lang = opts.lang ?? pres0.lang ?? (pres0.subtitles || {}).lang ?? null
this.playing = false
this.started = false
this.deferMedia = !opts.edit
this.mediaLoaded = !!opts.edit
this.pendingMedia = []
this.vol = Math.min(1, Math.max(0, parseFloat(localStorage.getItem('pp-vol') ?? '1') || 0))
this.muted = localStorage.getItem('pp-muted') === '1'
this.t = !opts.edit && pres0.poster ? Math.max(0, pres0.poster) : 0
this.stateKey = 'pp:' + (pres0.title || 'presentation')
this.resumePlay = false
if (!opts.edit){
const saved = this.readState()
if (saved){
this.t = saved.t
this.started = true
this.resumePlay = true
}
}
PresentationPlayer.instances.add(this)
this.clockStart = 0
this.items = []
this.videos = []
this.audio = null
this.audioStart = 0
this.subIndex = -1
this.raf = null
this.blobCache = {}
this.updateGen = 0
this.loaded = false
this.buildShell()
this.update(data.presentation)
}
mediaJob(jobs, job){
if (this.mediaLoaded) jobs.push(job())
else this.pendingMedia.push(job)
}
loadMedia(){
if (this.mediaLoaded) return Promise.resolve()
this.mediaLoaded = true
this.root.classList.add('pp-loading')
return Promise.all(this.pendingMedia.splice(0).map(job => job())).then(() => {
this.root.classList.remove('pp-loading')
this.applyTime(this.t, true)
this.updateTime()
})
}
loadBlob(url){
if (!this.blobCache[url]) this.blobCache[url] = fetch(url)
.then(r => {
if (!r.ok) throw Error(String(r.status))
return r.blob()
})
.then(b => URL.createObjectURL(b))
.catch(() => url)
return this.blobCache[url]
}
guard(promise, ms){
return Promise.race([promise, new Promise(res => setTimeout(res, ms))])
}
src(name){
return this.mediaBase + name
}
srcFor(item){
if (item && this.lang && Array.isArray(item.alts)){
const alt = item.alts.find(a => a && a.src && a.lang === this.lang)
if (alt) return alt.src
}
return item ? item.src : ''
}
readState(){
try {
const saved = JSON.parse(sessionStorage.getItem(this.stateKey))
if (saved && saved.t > 0.5 && Date.now() - saved.ts < 600000) return saved
}
catch {}
return null
}
saveState(){
try { sessionStorage.setItem(this.stateKey, JSON.stringify({t: this.t, ts: Date.now()})) }
catch {}
}
destroy(){
if (this.playing && !this.opts.edit) this.saveState()
this.playing = false
clearTimeout(this.idleTimer)
if (this.raf) cancelAnimationFrame(this.raf)
if (this.ticker) clearInterval(this.ticker)
if (this.audio) this.audio.pause()
for (const v of this.videos) v.pause()
this.audio = null
this.videos = []
for (const entry of Object.values(this.blobCache)) Promise.resolve(entry).then(url => typeof url === 'string' && url.startsWith('blob:') && URL.revokeObjectURL(url))
this.blobCache = {}
}
clearState(){
try { sessionStorage.removeItem(this.stateKey) }
catch {}
}
effVol(){
return this.muted ? 0 : this.vol
}
applyVolume(){
const eff = this.effVol()
if (this.audio) this.audio.volume = eff
for (const v of this.videos) v.volume = (v._baseVol ?? 0) * eff
if (this.volBtn) this.volBtn.innerHTML = this.icon(eff > 0 ? 'vol' : 'mute')
if (this.volBtn) this.volBtn.setAttribute('aria-label', eff > 0 ? 'Mute' : 'Unmute')
if (this.volEl && !this.muted) this.volEl.value = Math.round(this.vol * 100)
}
setVol(vol){
this.vol = Math.min(1, Math.max(0, vol))
this.muted = false
try {
localStorage.setItem('pp-vol', String(this.vol))
localStorage.setItem('pp-muted', '0')
}
catch {}
this.applyVolume()
}
toggleMute(){
this.muted = !this.muted
try { localStorage.setItem('pp-muted', this.muted ? '1' : '0') }
catch {}
this.applyVolume()
}
fullscreen(){
if (document.fullscreenElement) document.exitFullscreen()
else (this.root.closest('.pp-standalone') || this.root).requestFullscreen()
}
contentFor(item){
if (item && this.lang && Array.isArray(item.alts)){
const alt = item.alts.find(a => a && a.content && a.lang === this.lang)
if (alt) return alt.content
}
return item && item.content ? item.content : ''
}
languages(){
const set = new Set()
const p = this.pres || {}
const add = it => {
if (!it) return
for (const a of it.alts || []) if (a && a.lang) set.add(a.lang)
}
add(p.audio)
for (const v of p.videos || []) add(v)
for (const i of p.images || []) add(i)
for (const t of p.texts || []) add(t)
if (this.transcript && this.transcript.translations) for (const l of Object.keys(this.transcript.translations)) set.add(l)
return [...set].filter(l => l && l !== p.lang)
}
setLang(lang){
this.lang = lang || null
this.update(this.pres)
}
get duration(){
const p = this.pres
let end = 0
for (const img of p.images || []) end = Math.max(end, (img.start || 0) + (img.duration || 0))
for (const tx of p.texts || []) end = Math.max(end, (tx.start || 0) + (tx.duration || 0))
for (const v of this.videos) if (v.duration) end = Math.max(end, v._start + v.duration)
if (p.audio && p.audio.duration) end = Math.max(end, (p.audio.start || 0) + p.audio.duration)
return end || 10
}
buildShell(){
this.root.classList.add('pp-root', 'pp-paused')
if (this.opts.edit) this.root.classList.add('pp-edit')
this.stage = document.createElement('div')
this.stage.className = 'pp-stage'
this.root.appendChild(this.stage)
this.subsEl = document.createElement('div')
this.subsEl.className = 'pp-subs'
this.subsEl.style.display = 'none'
this.root.appendChild(this.subsEl)
if (this.opts.controls !== false && !this.opts.edit) this.buildControls()
const fit = w => this.root.style.fontSize = Math.max(9, w / 48) + 'px'
new ResizeObserver(entries => fit(entries[0].contentRect.width)).observe(this.root)
}
icon(name){
const paths = {
play: 'M8 5v14l11-7z',
pause: 'M6 5h4v14H6zM14 5h4v14h-4z',
cc: 'M4 5h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm4.5 5.5a2 2 0 0 0-2-1.5c-1.4 0-2.5 1.3-2.5 3s1.1 3 2.5 3a2 2 0 0 0 2-1.5H6.8a1 1 0 0 1-.8.5c-.7 0-1.5-.8-1.5-2s.8-2 1.5-2a1 1 0 0 1 .8.5zm8 0a2 2 0 0 0-2-1.5c-1.4 0-2.5 1.3-2.5 3s1.1 3 2.5 3a2 2 0 0 0 2-1.5h-1.7a1 1 0 0 1-.8.5c-.7 0-1.5-.8-1.5-2s.8-2 1.5-2a1 1 0 0 1 .8.5z',
vol: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z',
mute: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z',
full: 'M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z',
}
return '<svg viewBox="0 0 24 24"><path d="' + paths[name] + '"/></svg>'
}
buildControls(){
this.root.tabIndex = 0
this.bigPlay = document.createElement('button')
this.bigPlay.className = 'pp-bigplay'
this.bigPlay.setAttribute('aria-label', 'Play')
this.bigPlay.innerHTML = this.icon('play')
this.bigPlay.addEventListener('click', () => this.toggle())
this.root.appendChild(this.bigPlay)
const bar = document.createElement('div')
bar.className = 'pp-controls'
this.playBtn = document.createElement('button')
this.playBtn.setAttribute('aria-label', 'Play')
this.playBtn.innerHTML = this.icon('play')
this.playBtn.addEventListener('click', () => this.toggle())
this.timeEl = document.createElement('span')
this.timeEl.className = 'pp-time'
this.seekEl = document.createElement('input')
this.seekEl.type = 'range'
this.seekEl.className = 'pp-seek'
this.seekEl.setAttribute('aria-label', 'Seek')
this.seekEl.min = 0
this.seekEl.max = 1000
this.seekEl.value = 0
this.seekEl.addEventListener('input', () => this.seek(this.seekEl.value / 1000 * this.duration))
this.volBtn = document.createElement('button')
this.volBtn.setAttribute('aria-label', 'Mute')
this.volBtn.innerHTML = this.icon('vol')
this.volBtn.addEventListener('click', () => this.toggleMute())
this.volEl = document.createElement('input')
this.volEl.type = 'range'
this.volEl.className = 'pp-vol'
this.volEl.setAttribute('aria-label', 'Volume')
this.volEl.min = 0
this.volEl.max = 100
this.volEl.value = Math.round(this.vol * 100)
this.volEl.addEventListener('input', () => this.setVol(this.volEl.value / 100))
this.ccBtn = document.createElement('button')
this.ccBtn.setAttribute('aria-label', 'Subtitles')
this.ccBtn.innerHTML = this.icon('cc')
this.ccBtn.addEventListener('click', () => this.setSubs(!this.showSubs))
this.langSel = document.createElement('select')
this.langSel.className = 'pp-lang'
this.langSel.setAttribute('aria-label', 'Language')
this.langSel.style.display = 'none'
this.langSel.addEventListener('change', () => this.setLang(this.langSel.value === 'original' ? null : this.langSel.value))
this.fsBtn = document.createElement('button')
this.fsBtn.setAttribute('aria-label', 'Fullscreen')
this.fsBtn.innerHTML = this.icon('full')
this.fsBtn.addEventListener('click', () => this.fullscreen())
bar.append(this.playBtn, this.timeEl, this.seekEl, this.volBtn, this.volEl, this.ccBtn, this.langSel, this.fsBtn)
this.root.appendChild(bar)
this.stage.addEventListener('click', e => {
if (!e.target.closest('a')) this.toggle()
})
const wake = () => {
this.root.classList.remove('pp-idle')
clearTimeout(this.idleTimer)
this.idleTimer = setTimeout(() => this.playing && this.root.classList.add('pp-idle'), 2800)
}
this.wake = wake
this.root.addEventListener('mousemove', wake)
this.root.addEventListener('touchstart', wake, {passive: true})
}
update(pres){
this.pres = pres
const gen = ++this.updateGen
const jobs = []
this.pendingMedia = []
this.root.style.setProperty('--pp-ar', (pres.size.w / pres.size.h).toFixed(4))
this.stage.innerHTML = ''
this.items = []
this.videos = []
const wantAudio = pres.audio && pres.audio.src ? this.src(this.srcFor(pres.audio)) : ''
this.audioStart = pres.audio && pres.audio.start ? pres.audio.start : 0
if (this.audio && this.audioSrc !== wantAudio){
this.audio.pause()
this.audio = null
}
if (wantAudio && !this.audio){
this.audioSrc = wantAudio
this.audio = new Audio()
this.audio.preload = 'auto'
this.audio.volume = this.effVol()
this.mediaJob(jobs, () => this.guard(this.loadBlob(this.audioSrc).then(url => new Promise(res => {
this.audio.addEventListener('loadedmetadata', () => {
const d = this.audio.duration
if (Number.isFinite(d) && d > 0 && this.opts.onMeta) this.opts.onMeta(d)
res()
}, {once: true})
this.audio.addEventListener('error', () => res(), {once: true})
this.audio.src = url
})), 20000))
}
for (const cfg of pres.videos || []){
const v = document.createElement('video')
v.className = 'pp-video'
v.preload = 'auto'
v.playsInline = true
v._baseVol = Math.min(1, Math.max(0, (cfg.volume ?? 100) / 100))
v.volume = v._baseVol * this.effVol()
v.muted = !cfg.volume
v.style.objectFit = cfg.fit || 'cover'
v._start = cfg.start || 0
v._inDur = cfg.in && cfg.in.dur ? cfg.in.dur : 0
v._outDur = cfg.out && cfg.out.dur ? cfg.out.dur : 0
if (cfg.x != null || cfg.y != null || cfg.w != null){
v.classList.add('pp-video-box')
v.style.left = (cfg.x || 0) + '%'
v.style.top = (cfg.y || 0) + '%'
v.style.width = (cfg.w || 30) + '%'
v.style.zIndex = 10 + (cfg.z || 0)
}
else v.style.zIndex = 1000
if (cfg.frame){
v.style.borderRadius = '20px'
v.style.boxShadow = '0 26px 70px rgba(0,0,0,.55)'
}
this.mediaJob(jobs, () => this.guard(this.loadBlob(this.src(this.srcFor(cfg))).then(url => new Promise(res => {
v.addEventListener('loadedmetadata', () => res(), {once: true})
v.addEventListener('error', () => res(), {once: true})
v.src = url
})), 30000))
this.stage.appendChild(v)
this.videos.push(v)
}
const sorted = (pres.images || []).map((cfg, idx) => ({cfg, idx})).sort((a, b) => (a.cfg.z || 0) - (b.cfg.z || 0))
for (const {cfg, idx} of sorted){
const el = document.createElement('div')
el.className = 'pp-item'
el.dataset.idx = idx
el.style.left = (cfg.x || 0) + '%'
el.style.top = (cfg.y || 0) + '%'
el.style.width = (cfg.w || 30) + '%'
el.style.zIndex = 10 + (cfg.z || 0)
const du = cfg.during && PresentationPlayer.durings[cfg.during.name]
if (du) for (const [key, val] of Object.entries(du.vars(cfg.during.amount ?? du.amount, cfg.during.dir || du.dir))) el.style.setProperty(key, val)
const img = document.createElement('img')
img.src = this.src(this.srcFor(cfg))
img.alt = cfg.alt || ''
img.draggable = false
img.addEventListener('load', () => {
img.width = img.naturalWidth
img.height = img.naturalHeight
}, {once: true})
if (!img.complete) jobs.push(this.guard(new Promise(res => {
img.addEventListener('load', () => res(), {once: true})
img.addEventListener('error', () => res(), {once: true})
}), 15000))
if (cfg.link && cfg.link.href && !this.opts.edit){
const a = document.createElement('a')
a.href = cfg.link.href
if (cfg.link.target === '_blank'){
a.target = '_blank'
a.rel = 'noopener'
}
a.appendChild(img)
el.appendChild(a)
}
else el.appendChild(img)
this.stage.appendChild(el)
this.items.push({cfg, el, phase: null})
}
const sortedTexts = (pres.texts || []).map((cfg, idx) => ({cfg, idx})).sort((a, b) => (a.cfg.z || 0) - (b.cfg.z || 0))
for (const {cfg, idx} of sortedTexts){
const el = document.createElement('div')
el.className = 'pp-text'
el.dataset.idx = idx
el.style.left = (cfg.x || 0) + '%'
el.style.top = (cfg.y || 0) + '%'
el.style.width = (cfg.w || 80) + '%'
el.style.zIndex = 2000 + (cfg.z || 0)
el.style.fontFamily = cfg.font || 'system-ui, sans-serif'
el.style.fontSize = (cfg.size || 6) + 'cqh'
el.style.color = cfg.color || '#ffffff'
el.style.textAlign = cfg.align || 'center'
if (cfg.bold) el.style.fontWeight = 'bold'
const du = cfg.during && PresentationPlayer.durings[cfg.during.name]
if (du) for (const [key, val] of Object.entries(du.vars(cfg.during.amount ?? du.amount, cfg.during.dir || du.dir))) el.style.setProperty(key, val)
let words = null
if (cfg.kinetic){
words = []
let wi = 0
for (const tok of this.contentFor(cfg).split(/(\s+)/)){
if (tok === '') continue
if (/^\s+$/.test(tok)){
el.appendChild(document.createTextNode(tok))
continue
}
const span = document.createElement('span')
span.className = 'pp-word'
span.textContent = tok
el.appendChild(span)
words.push({span, i: wi})
wi++
}
}
else el.textContent = this.contentFor(cfg)
this.stage.appendChild(el)
this.items.push({cfg, el, phase: null, words})
}
this.showSubs = (pres.subtitles || {}).show !== false
this.subIndex = -1
this.loaded = false
this.root.classList.add('pp-loading')
Promise.all(jobs).then(() => {
if (gen !== this.updateGen) return
this.loaded = true
this.root.classList.remove('pp-loading')
this.applyTime(this.t, true)
this.updateTime()
if (this.resumePlay){
this.resumePlay = false
this.play()
setTimeout(() => {
if (this.playing && this.audio && this.audio.paused) this.pause()
}, 350)
}
if (this.opts.onLoaded) this.opts.onLoaded()
})
this.applyTime(this.t, true)
this.updateTime()
this.refreshLangSel()
if (this.ccBtn){
this.ccBtn.style.display = (this.segments() || []).length ? '' : 'none'
this.ccBtn.classList.toggle('pp-off', !this.showSubs)
}
this.applyVolume()
}
segments(){
if (!this.transcript) return null
const tr = this.transcript.translations || {}
if (this.lang && tr[this.lang]) return tr[this.lang]
return this.transcript.segments || null
}
setSubs(show){
this.showSubs = show
if (this.ccBtn) this.ccBtn.classList.toggle('pp-off', !show)
this.subIndex = -1
this.applySubs(this.t)
}
refreshLangSel(){
if (!this.langSel) return
const langs = this.opts.langSelector === false ? [] : this.languages()
if (!langs.length){
this.langSel.style.display = 'none'
return
}
this.langSel.innerHTML = ''
const opt = (value, label) => {
const o = document.createElement('option')
o.value = value
o.textContent = label
this.langSel.appendChild(o)
}
opt('original', (this.pres.lang || 'original').toUpperCase())
for (const l of langs) opt(l, l.toUpperCase())
this.langSel.value = (this.lang && this.lang !== this.pres.lang) ? this.lang : 'original'
this.langSel.style.display = ''
}
toggle(){
this.playing ? this.pause() : this.play()
}
play(){
if (!this.loaded) return
if (!this.mediaLoaded){
this.loadMedia().then(() => this.play())
return
}
if (!this.started && this.t > 0) this.seek(0)
this.started = true
if (this.t >= this.duration - 0.05) this.seek(0)
this.playing = true
this.root.classList.remove('pp-paused')
if (this.wake) this.wake()
if (this.bigPlay) this.bigPlay.style.display = 'none'
if (this.playBtn) this.playBtn.innerHTML = this.icon('pause')
if (this.playBtn) this.playBtn.setAttribute('aria-label', 'Pause')
this.clockStart = performance.now() - this.t * 1000
this.syncVideos(true)
if (this.audio) this.syncAudio(true)
for (const item of this.items) item.el.style.animationPlayState = 'running'
const step = () => {
if (!this.playing) return
this.t = (performance.now() - this.clockStart) / 1000
const tick = Math.floor(this.t)
if (tick !== this.savedTick){
this.savedTick = tick
this.saveState()
}
if (this.t >= this.duration){
this.t = this.duration
this.pause(true)
}
else this.applyTime(this.t)
this.updateTime()
if (this.opts.onTime) this.opts.onTime(this.t)
if (this.playing) this.raf = requestAnimationFrame(step)
}
this.raf = requestAnimationFrame(step)
this.ticker = setInterval(() => { if (document.hidden) step() }, 250)
}
pause(ended){
this.playing = false
clearTimeout(this.idleTimer)
this.root.classList.remove('pp-idle')
if (!this.opts.edit) this.clearState()
this.root.classList.add('pp-paused')
if (this.raf) cancelAnimationFrame(this.raf)
if (this.ticker) clearInterval(this.ticker)
if (this.audio) this.audio.pause()
for (const v of this.videos) v.pause()
for (const item of this.items) item.el.style.animationPlayState = 'paused'
if (this.bigPlay) this.bigPlay.style.display = ''
if (this.playBtn) this.playBtn.innerHTML = this.icon('play')
if (this.playBtn) this.playBtn.setAttribute('aria-label', 'Play')
if (ended && this.opts.onEnded) this.opts.onEnded()
}
seek(t){
this.t = Math.min(Math.max(0, t), this.duration)
this.clockStart = performance.now() - this.t * 1000
this.applyTime(this.t, true)
this.updateTime()
if (this.opts.onTime) this.opts.onTime(this.t)
}
applyTime(t, force){
for (const item of this.items){
const cfg = item.cfg
const start = cfg.start || 0
const end = start + (cfg.duration || 0)
const inDur = cfg.in && PresentationPlayer.transitions[cfg.in.name] ? (cfg.in.dur || 0) : 0
const outDur = cfg.out && PresentationPlayer.transitions[cfg.out.name] ? (cfg.out.dur || 0) : 0
let phase = 'hidden'
if (t >= start && t < end){
if (t < start + inDur) phase = 'in'
else if (t > end - outDur) phase = 'out'
else phase = 'on'
}
if (phase !== item.phase || force) this.setPhase(item, phase, t)
}
for (const item of this.items){
if (!item.words) continue
const start = item.cfg.start || 0
if (t < start || t >= start + (item.cfg.duration || 0)) continue
for (const {span, i} of item.words){
const st = PresentationPlayer.popState(t - start - i * 0.06)
span.style.opacity = st.o
span.style.transform = 'translateY(' + st.y + 'em) scale(' + st.s + ')'
}
}
this.syncVideos(force)
if (this.audio) this.syncAudio(force)
this.applySubs(t)
}
static popState(dt){
const D = 0.34
if (dt <= 0) return {o: 0, s: 0.82, y: 0.28}
if (dt >= D) return {o: 1, s: 1, y: 0}
const p = dt / D
const e = 1 + 2.70158 * Math.pow(p - 1, 3) + 1.70158 * Math.pow(p - 1, 2)
return {o: Math.min(1, p * 1.8), s: 0.82 + 0.18 * e, y: 0.28 * (1 - e)}
}
setPhase(item, phase, t){
const el = item.el
const cfg = item.cfg
item.phase = phase
if (phase === 'hidden'){
el.style.display = 'none'
return
}
el.style.display = 'block'
const start = cfg.start || 0
const end = start + (cfg.duration || 0)
const names = []
const durs = []
const delays = []
const eases = []
const counts = []
el.style.transformOrigin = ''
if (phase !== 'on'){
const dir = phase === 'in' ? 'in' : 'out'
const spec = PresentationPlayer.transitions[cfg[dir].name]
const dur = cfg[dir].dur || 0.5
const elapsed = dir === 'in' ? t - start : t - (end - dur)
names.push(spec[dir])
durs.push(dur + 's')
delays.push((-Math.max(0, elapsed)).toFixed(3) + 's')
eases.push(spec.ease)
counts.push('1')
el.style.transformOrigin = (dir === 'in' ? spec.originIn : spec.originOut) || ''
}
const du = cfg.during && PresentationPlayer.durings[cfg.during.name]
if (du){
names.push(du.css)
durs.push((du.period || Math.max(0.1, end - start)) + 's')
delays.push((-Math.max(0, t - start)).toFixed(3) + 's')
eases.push(du.ease)
counts.push(du.period ? 'infinite' : '1')
}
if (!names.length){
el.style.animation = 'none'
return
}
el.style.animationName = names.join(', ')
el.style.animationDuration = durs.join(', ')
el.style.animationDelay = delays.join(', ')
el.style.animationTimingFunction = eases.join(', ')
el.style.animationIterationCount = counts.join(', ')
el.style.animationFillMode = 'both'
el.style.animationPlayState = this.playing ? 'running' : 'paused'
}
syncVideos(force){
for (const v of this.videos) this.syncMedia(v, v._start, force)
}
syncAudio(force){
this.syncMedia(this.audio, this.audioStart, force)
}
syncMedia(el, start, force){
const known = Number.isFinite(el.duration) && el.duration > 0
const show = known && this.t >= start && this.t < start + el.duration
if (el.style) el.style.display = show ? '' : 'none'
if (el.style && el.tagName === 'VIDEO'){
const end = start + (el.duration || 0)
let op = 1
if (el._inDur && this.t < start + el._inDur) op = (this.t - start) / el._inDur
else if (el._outDur && this.t > end - el._outDur) op = (end - this.t) / el._outDur
el.style.opacity = Math.max(0, Math.min(1, op)).toFixed(3)
}
if (el.readyState < 1) return
if (!show){
if (!el.paused) el.pause()
return
}
const target = this.t - start
if (force || Math.abs(el.currentTime - target) > 0.35) el.currentTime = Math.min(Math.max(0, target), el.duration || target)
if (this.playing && el.paused) el.play().catch(() => {})
if (!this.playing && !el.paused) el.pause()
}
applySubs(t){
const segs = this.showSubs ? this.segments() : null
if (!segs || !segs.length){
this.subsEl.style.display = 'none'
return
}
let current = null
for (const seg of segs){
if (t >= seg.start && t <= seg.end){
current = seg
break
}
}
if (current){
this.subsEl.textContent = current.text
this.subsEl.style.display = ''
}
else this.subsEl.style.display = 'none'
}
fmt(t){
const m = Math.floor(t / 60)
const s = Math.floor(t - m * 60)
return m + ':' + String(s).padStart(2, '0')
}
updateTime(){
const shown = this.started ? this.t : 0
if (this.timeEl) this.timeEl.textContent = this.fmt(shown) + ' / ' + this.fmt(this.duration)
if (this.seekEl && !this.seeking) this.seekEl.value = this.duration ? Math.round(shown / this.duration * 1000) : 0
}
static sweep(){
for (const player of this.instances){
if (player.root.isConnected) continue
player.destroy()
this.instances.delete(player)
}
this.boot()
}
static boot(){
for (const root of document.querySelectorAll('.pp-embed')){
if (root.dataset.ppBooted) continue
const data = root.querySelector('script[type="application/json"]')
if (data){
root.dataset.ppBooted = '1'
this.wire(root, JSON.parse(data.textContent))
continue
}
if (!root.dataset.src) continue
root.dataset.ppBooted = '1'
fetch(root.dataset.src)
.then(r => r.json())
.then(json => {
if (!root.isConnected) return
const payload = json.presentation ? json : {presentation: json, mediaBase: root.dataset.src.replace(/[^\/]*$/, '') + 'media/'}
this.wire(root, payload)
})
.catch(e => console.warn('PresentationPlayer: failed to load', root.dataset.src, e))
}
}
static wire(root, payload){
const forced = 'lang' in root.dataset
new PresentationPlayer(root, payload, {controls: true, lang: root.dataset.lang || null, langSelector: !forced})
}
static keys = {
' ': player => player.toggle(),
k: player => player.toggle(),
m: player => player.toggleMute(),
c: player => (player.segments() || []).length && player.setSubs(!player.showSubs),
f: player => player.fullscreen(),
home: player => player.seek(0),
end: player => player.seek(player.duration),
arrowleft: player => player.seek(player.t - 5),
arrowright: player => player.seek(player.t + 5),
arrowup: player => player.setVol(player.vol + 0.05),
arrowdown: player => player.setVol(player.vol - 0.05),
}
static keyTarget(){
const players = [...this.instances].filter(p => p.root.isConnected && p.playBtn)
if (document.fullscreenElement){
const full = players.find(p => document.fullscreenElement.contains(p.root))
if (full) return full
}
return players.find(p => p.root.contains(document.activeElement)) || (players.length === 1 ? players[0] : null)
}
static key(e){
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return
const action = this.keys[e.key.toLowerCase()]
if (!action) return
const el = e.target
if (el.closest && el.closest('input, textarea, select, [contenteditable]') && !el.classList.contains('pp-seek') && !el.classList.contains('pp-vol')) return
const player = this.keyTarget()
if (!player) return
e.preventDefault()
action(player)
if (player.wake) player.wake()
}
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => PresentationPlayer.boot())
else PresentationPlayer.boot()
new MutationObserver(() => PresentationPlayer.sweep()).observe(document.documentElement, {childList: true, subtree: true})
document.addEventListener('keydown', e => PresentationPlayer.key(e))view
style
line 1022
.pp-root {
position: relative
background: #000
overflow: hidden
user-select: none
font-family: system-ui, sans-serif
}
.pp-stage {
position: absolute
inset: 0
overflow: hidden
container-type: size
}
.pp-video {
position: absolute
inset: 0
width: 100%
height: 100%
object-fit: cover
}
.pp-video-box {
right: auto
bottom: auto
height: auto
}
.pp-item {
position: absolute
will-change: transform, opacity, clip-path
img {
display: block
width: 100%
height: auto
}
a {
display: block
cursor: pointer
}
}
.pp-text {
position: absolute
will-change: transform, opacity
white-space: pre-wrap
overflow-wrap: break-word
line-height: 1.25
text-shadow: 0 1px 3px rgba(0,0,0,.55)
.pp-word {
display: inline-block
will-change: transform, opacity
}
}
.pp-subs {
position: absolute
left: 50%
bottom: 5%
transform: translateX(-50%)
max-width: 84%
padding: .3em .8em
background: rgba(0,0,0,.55)
color: #fff
font-size: 1.5em
line-height: 1.35
text-align: center
border-radius: .3em
pointer-events: none
z-index: 3000
}
.pp-bigplay {
position: absolute
left: 50%
top: 50%
transform: translate(-50%,-50%)
width: 5em
height: 5em
border-radius: 50%
background: rgba(0,0,0,.6)
border: 2px solid rgba(255,255,255,.8)
color: #fff
cursor: pointer
z-index: 3050
display: flex
align-items: center
justify-content: center
transition: transform .15s ease
\:hover: transform: translate(-50%,-50%) scale(1.08)
svg {
width: 2em
height: 2em
fill: #fff
margin-left: .3em
}
}
.pp-controls {
position: absolute
left: 0
right: 0
bottom: 0
display: flex
align-items: center
gap: .8em
padding: 2.2em .9em .5em
background: linear-gradient(transparent, rgba(0,0,0,.75))
z-index: 3040
opacity: 0
transition: opacity .25s ease
font-size: .9em
}
.pp-root:hover:not(.pp-idle) .pp-controls, .pp-root.pp-paused .pp-controls: opacity: 1
.pp-root.pp-idle: cursor: none
.pp-root:focus-visible {
outline: 2px solid #FFB347
outline-offset: -2px
}
.pp-controls button {
background: none
border: 0
color: #fff
cursor: pointer
font-size: 1em
padding: .2em .4em
opacity: .9
\:hover: opacity: 1
svg {
width: 1.4em
height: 1.4em
fill: #fff
display: block
}
}
.pp-controls button.pp-off: opacity: .35
.pp-controls input.pp-vol {
accent-color: #FFB347
cursor: pointer
width: 64px
}
.pp-controls select.pp-lang {
background: rgba(0,0,0,.45)
color: #fff
border: 1px solid rgba(255,255,255,.3)
border-radius: .3em
font: inherit
font-size: .82em
padding: .15em .35em
cursor: pointer
option: color: #000
}
.pp-time {
color: #fff
font-variant-numeric: tabular-nums
font-size: .95em
white-space: nowrap
}
.pp-seek {
flex: 1
accent-color: #FFB347
cursor: pointer
height: 1.2em
margin: 0
}
.pp-loading .pp-bigplay {
pointer-events: none
opacity: .6
border-top-color: #FFB347
animation: pp-spin 1s linear infinite
svg: opacity: .3
}
.pp-edit {
outline: 1px solid rgba(108,192,255,.7)
box-shadow: 0 0 0 1px rgba(0,0,0,.6), 0 0 0 4px rgba(108,192,255,.12), 0 10px 44px rgba(0,0,0,.6)
}
.pp-edit .pp-item {
cursor: move
outline: 1px dashed transparent
\:hover: outline: 1px dashed rgba(255,179,71,.6)
}
.pp-edit .pp-item.pp-sel {
outline: 2px solid #FFB347
z-index: 899
}
.pp-handle {
position: absolute
right: -7px
bottom: -7px
width: 14px
height: 14px
background: #FFB347
border-radius: 3px
cursor: nwse-resize
z-index: 901
}
.pp-edit .pp-text {
cursor: move
outline: 1px dashed transparent
\:hover: outline: 1px dashed rgba(255,179,71,.6)
}
.pp-edit .pp-text.pp-sel {
outline: 2px solid #FFB347
z-index: 899
}
.pp-standalone {
position: fixed
inset: 0
display: flex
align-items: center
justify-content: center
background: #000
.pp-root {
width: 100vw
height: 100vh
}
@supports (aspect-ratio: 16 / 9) {
.pp-root {
width: min(100vw, calc(100dvh * var(--pp-ar, 1.7778)))
height: min(100dvh, calc(100vw / var(--pp-ar, 1.7778)))
}
}
}
@keyframes pp-spin {
0%: transform: translate(-50%,-50%) rotate(0deg)
100%: transform: translate(-50%,-50%) rotate(360deg)
}
@keyframes pp-fade-in {
0%: opacity: 0
100%: opacity: 1
}
@keyframes pp-fade-out {
0%: opacity: 1
100%: opacity: 0
}
@keyframes pp-zoom-in {
0% {
opacity: 0
transform: scale(.96)
}
100% {
opacity: 1
transform: scale(1)
}
}
@keyframes pp-zoom-out {
0% {
opacity: 1
transform: scale(1)
}
100% {
opacity: 0
transform: scale(1.05)
}
}
@keyframes pp-glide-in {
0%: transform: translateX(-100dvw)
100%: transform: translateX(0)
}
@keyframes pp-glide-out {
0% {
opacity: 1
transform: translateX(0)
}
100% {
opacity: 0
transform: translateX(100dvw)
}
}
@keyframes pp-slide-in {
0% {
opacity: 0
transform: translateX(-20px)
}
100% {
opacity: 1
transform: translateX(0)
}
}
@keyframes pp-slide-out {
0% {
opacity: 1
transform: translateX(0)
}
100% {
opacity: 0
transform: translateX(20px)
}
}
@keyframes pp-drop-in {
0% {
opacity: 0
transform: translateY(-50px)
}
100% {
opacity: 1
transform: translateY(0)
}
}
@keyframes pp-drop-out {
0% {
opacity: 1
transform: translateY(0)
}
100% {
opacity: 0
transform: translateY(50px)
}
}
@keyframes pp-skew-in {
0% {
opacity: 0
transform: skewX(-15deg)
}
100% {
opacity: 1
transform: skewX(0deg)
}
}
@keyframes pp-skew-out {
0% {
opacity: 1
transform: skewX(0deg)
}
100% {
opacity: 0
transform: skewX(15deg)
}
}
@keyframes pp-tilt-in {
0% {
opacity: 0
transform: rotateZ(-5deg) translateY(20px)
}
100% {
opacity: 1
transform: rotateZ(0deg) translateY(0)
}
}
@keyframes pp-tilt-out {
0% {
opacity: 1
transform: rotateZ(0deg) translateY(0)
}
100% {
opacity: 0
transform: rotateZ(5deg) translateY(-20px)
}
}
@keyframes pp-spiral-in {
0% {
opacity: 0
transform: rotate(-360deg) scale(0)
}
100% {
opacity: 1
transform: rotate(0deg) scale(1)
}
}
@keyframes pp-spiral-out {
0% {
opacity: 1
transform: rotate(0deg) scale(1)
}
100% {
opacity: 0
transform: rotate(360deg) scale(0)
}
}
@keyframes pp-ripple-in {
0% {
opacity: 0
transform: scale(.5)
filter: blur(3px)
}
100% {
opacity: 1
transform: scale(1)
filter: blur(0px)
}
}
@keyframes pp-ripple-out {
0% {
opacity: 1
transform: scale(1)
filter: blur(0px)
}
100% {
opacity: 0
transform: scale(1.5)
filter: blur(3px)
}
}
@keyframes pp-curtain-in {
0% {
opacity: 0
transform: scaleY(0)
transform-origin: bottom
}
100% {
opacity: 1
transform: scaleY(1)
transform-origin: bottom
}
}
@keyframes pp-curtain-out {
0% {
opacity: 1
transform: scaleY(1)
transform-origin: top
}
100% {
opacity: 0
transform: scaleY(0)
transform-origin: top
}
}
@keyframes pp-tv-in {
0% {
opacity: 0
transform: scale(0, 0)
}
50% {
opacity: 0.8
transform: scale(1, 0.02)
}
100% {
opacity: 1
transform: scale(1, 1)
}
}
@keyframes pp-tv-out {
0% {
opacity: 1
transform: scale(1, 1)
}
50% {
opacity: 0.8
transform: scale(1, 0.02)
}
100% {
opacity: 0
transform: scale(0, 0)
}
}
@keyframes pp-flip-in {
0% {
transform: perspective(1600px) rotateY(-180deg)
opacity: 0
}
50%: opacity: 0
50.1%: opacity: 1
100% {
transform: perspective(1600px) rotateY(0deg)
opacity: 1
}
}
@keyframes pp-flip-out {
0% {
transform: perspective(1600px) rotateY(0deg)
opacity: 1
}
49.9%: opacity: 1
50%: opacity: 0
100% {
transform: perspective(1600px) rotateY(180deg)
opacity: 0
}
}
@keyframes pp-cube-in {
0% {
transform: rotateY(90deg) translateZ(0)
opacity: 0
}
100% {
transform: rotateY(0deg) translateZ(0)
opacity: 1
}
}
@keyframes pp-cube-out {
0% {
transform: rotateY(0deg) translateZ(0)
opacity: 1
}
100% {
transform: rotateY(-90deg) translateZ(0)
opacity: 0
}
}
@keyframes pp-diamond-in {
0%: clip-path: polygon(50% 50%,50% 50%,50% 50%,50% 50%)
100%: clip-path: polygon(50% -50%,150% 50%,50% 150%,-50% 50%)
}
@keyframes pp-diamond-out {
0%: clip-path: polygon(50% -50%,150% 50%,50% 150%,-50% 50%)
100%: clip-path: polygon(50% 50%,50% 50%,50% 50%,50% 50%)
}
@keyframes pp-diaphragm-in {
0%: clip-path: circle(0% at 50% 50%)
100%: clip-path: circle(90% at 50% 50%)
}
@keyframes pp-diaphragm-out {
0%: clip-path: circle(90% at 50% 50%)
100%: clip-path: circle(0% at 50% 50%)
}
@keyframes pp-spotlight-in {
0%: clip-path: circle(0% at 50% 50%)
100%: clip-path: circle(75% at 50% 50%)
}
@keyframes pp-spotlight-out {
0%: clip-path: circle(75% at 50% 50%)
100%: clip-path: circle(0% at 50% 50%)
}
@keyframes pp-wipe-in {
0% {
clip-path: inset(0% 100% 0% 0%)
opacity: 0
}
40% {
clip-path: inset(0% 40% 0% 0%)
opacity: .9
}
100% {
clip-path: inset(0% 0% 0% 0%)
opacity: 1
}
}
@keyframes pp-wipe-out {
0% {
clip-path: inset(0% 0% 0% 0%)
opacity: 1
}
60% {
clip-path: inset(0% 0% 0% 60%)
opacity: .3
}
100% {
clip-path: inset(0% 0% 0% 100%)
opacity: 0
}
}
@keyframes pp-glitch-in {
0% {
clip-path: inset(100% 0 0 0)
transform: translate(10px, 0)
opacity: 0
}
20% {
clip-path: inset(10% 0 60% 0)
transform: translate(-10px, 5px)
}
40% {
clip-path: inset(80% 0 5% 0)
transform: translate(5px, -10px)
}
60% {
clip-path: inset(0 0 20% 0)
transform: translate(-5px, 5px)
}
80% {
clip-path: inset(40% 0 40% 0)
transform: translate(5px, 0)
}
100% {
clip-path: inset(0 0 0 0)
transform: translate(0)
opacity: 1
}
}
@keyframes pp-glitch-out {
0% {
clip-path: inset(0 0 0 0)
transform: translate(0)
}
20% {
clip-path: inset(20% 0 80% 0)
transform: translate(-5px, 5px)
}
40% {
clip-path: inset(80% 0 5% 0)
transform: translate(5px, -5px)
}
60% {
clip-path: inset(10% 0 60% 0)
transform: translate(-5px, 0)
}
80% {
clip-path: inset(50% 0 20% 0)
transform: translate(5px, 5px)
}
100% {
clip-path: inset(50% 50% 50% 50%)
transform: translate(0)
opacity: 0
}
}
@keyframes pp-cards-in {
0%: transform: translateY(100%)
100%: transform: translateY(0)
}
@keyframes pp-cards-out {
0% {
transform: translateY(0)
opacity: 1
}
100% {
transform: translateY(100%)
opacity: 0
}
}
@keyframes pp-scale {
0%: scale: var(--pp-s0, .9)
100%: scale: 1
}
@keyframes pp-drift {
0%: translate: var(--pp-x0, 5%) var(--pp-y0, 0%)
100%: translate: 0% 0%
}
@keyframes pp-kenburns {
0% {
scale: var(--pp-s0, .92)
translate: var(--pp-x0, -4%) var(--pp-y0, 4%)
}
100% {
scale: 1
translate: 0% 0%
}
}
@keyframes pp-float {
0%: translate: 0% 0%
50%: translate: 0% var(--pp-y1, -3%)
100%: translate: 0% 0%
}
@keyframes pp-sway {
0%: rotate: var(--pp-r0, -1.5deg)
50%: rotate: var(--pp-r1, 1.5deg)
100%: rotate: var(--pp-r0, -1.5deg)
}
@keyframes pp-pulse {
0%: scale: 1
50%: scale: var(--pp-s1, 1.02)
100%: scale: 1
}object
%recorder
/phlo/resources/DOM/recorder.phlo
view
script
line 11
class Recorder {
constructor(opts = {}){
this.mimeType = opts.mimeType || ''
this.log = opts.log || (() => {})
}
supported(){
return typeof MediaRecorder !== 'undefined' && !!(navigator.mediaDevices)
}
pickMime(prefs){
if (typeof MediaRecorder === 'undefined') return ''
for (const m of prefs) if (m && MediaRecorder.isTypeSupported(m)) return m
return ''
}
mixed(video, audioStreams){
const ctx = new AudioContext()
const dest = ctx.createMediaStreamDestination()
const sources = []
for (const s of audioStreams){
if (!s.getAudioTracks().length) continue
ctx.createMediaStreamSource(s).connect(dest)
sources.push(...s.getAudioTracks())
}
const out = new MediaStream([...video.getVideoTracks(), ...dest.stream.getAudioTracks()])
out.phloSources = sources
out.phloCtx = ctx
return out
}
async stream(opts = {}){
if (opts.stream) return opts.stream
if (opts.canvas){
const s = opts.canvas.captureStream(opts.fps || 30)
if (opts.mic){
const m = await navigator.mediaDevices.getUserMedia({audio: true})
m.getAudioTracks().forEach(t => s.addTrack(t))
}
return s
}
if (opts.screen){
const s = await navigator.mediaDevices.getDisplayMedia({video: opts.video == null ? true : opts.video, audio: opts.audio == null ? false : opts.audio})
if (opts.mic){
const m = await navigator.mediaDevices.getUserMedia({audio: true})
if (s.getAudioTracks().length) return this.mixed(s, [s, m])
m.getAudioTracks().forEach(t => s.addTrack(t))
}
return s
}
return navigator.mediaDevices.getUserMedia({video: opts.video == null ? true : opts.video, audio: opts.audio == null ? true : opts.audio})
}
async record(opts = {}){
if (!this.supported()) throw new Error('MediaRecorder not supported here')
const stream = await this.stream(opts)
const ownTracks = !opts.stream
const mime = opts.mimeType || this.mimeType || this.pickMime(['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'])
const recOpts = {}
if (mime) recOpts.mimeType = mime
if (opts.videoBitsPerSecond) recOpts.videoBitsPerSecond = opts.videoBitsPerSecond
if (opts.audioBitsPerSecond) recOpts.audioBitsPerSecond = opts.audioBitsPerSecond
const rec = new MediaRecorder(stream, recOpts)
const chunks = []
let stopResolve, stopReject
const stopped = new Promise((res, rej) => {
stopResolve = res
stopReject = rej
})
rec.ondataavailable = e => {
if (e.data && e.data.size){
chunks.push(e.data)
if (opts.onData) opts.onData(e.data)
}
}
rec.onstop = () => {
if (ownTracks){
for (const t of stream.getTracks()) t.stop()
for (const t of stream.phloSources || []) t.stop()
if (stream.phloCtx) stream.phloCtx.close().catch(() => {})
}
stopResolve(new Blob(chunks, {type: (rec.mimeType || mime || 'video/webm').split(';')[0]}))
}
rec.onerror = e => stopReject((e && e.error) || new Error('recording error'))
stream.getVideoTracks().forEach(t => t.addEventListener('ended', () => { if (rec.state !== 'inactive') rec.stop() }, {once: true}))
rec.start(opts.timeslice || undefined)
this.log('[recorder] started ' + (rec.mimeType || mime || 'default'))
return {
stream,
recorder: rec,
get state(){ return rec.state },
pause(){ if (rec.state === 'recording') rec.pause() },
resume(){ if (rec.state === 'paused') rec.resume() },
async stop(o = {}){
if (rec.state !== 'inactive') rec.stop()
const blob = await stopped
if (o.mp4 && typeof ffmpeg !== 'undefined' && blob.type.indexOf('mp4') === -1){
return ffmpeg.transcode(blob, {to: 'mp4', crf: o.crf, args: o.args, onProgress: o.onProgress, log: o.log})
}
return blob
},
}
}
}
const recorder = new Recorderobject
%shorthands
/phlo/resources/DOM/shorthands.phlo
view
script
line 11
Definieert afkortingsfuncties voor veelvoorkomende gebeurtenisluisteraars: onChange, onClick en onInput, die het proces van het koppelen van gebeurtenisbehandelaars aan elementen vereenvoudigen.
function onChange(els, cb){ on('change', els, cb) }
function onClick(els, cb){ on('click', els, cb) }
function onInput(els, cb){ on('input', els, cb) }object
%store
/phlo/resources/DOM/store.phlo
view
script
line 11
phlo.store = {
signals: {},
listeners: {},
calcs: {},
calcDeps: {},
calcVals: {},
calcTick: false,
formats: {},
persists: {},
syncs: {},
quiet: 0,
scope: 'page',
split: path => path.replace(/\]/g, '').split(/\.|\[/),
get(path){
if (!path) return undefined
let ctx = phlo.store.signals
const keys = phlo.store.split(path)
for (let i = 0; i < keys.length; i++){
if (ctx == null) return undefined
ctx = ctx[keys[i]]
}
return ctx
},
setPath(path, value){
let keys = phlo.store.split(path)
let ctx = phlo.store.signals
while (keys.length > 1){
const k = keys.shift()
ctx[k] ??= isNaN(keys[0]) ? {} : []
ctx = ctx[k]
}
const k = keys[0]
const old = ctx[k]
if (old === value) return false
ctx[k] = value
return true
},
set(path, value){
if (!phlo.store.setPath(path, value)) return
phlo.store.notify(path, phlo.store.get(path))
phlo.store.recalc(path)
phlo.store.schedule()
phlo.store.save(path)
if (!phlo.store.quiet) phlo.store.push(path)
},
on(path, cb, el = null){
(phlo.store.listeners[path] ??= new Set).add(cb)
if (el) phlo.store.owners.set(cb, el)
},
off(path, cb){
phlo.store.listeners[path]?.delete(cb)
phlo.store.owners.delete(cb)
},
owners: new WeakMap,
sweep(){
Object.keys(phlo.store.listeners).forEach(path => {
phlo.store.listeners[path].forEach(cb => {
const el = phlo.store.owners.get(cb)
if (el && !el.isConnected) phlo.store.off(path, cb)
})
if (!phlo.store.listeners[path].size) delete phlo.store.listeners[path]
})
},
reset(prefix = ''){
if (!prefix){
phlo.store.signals = {}
phlo.store.listeners = {}
phlo.store.calcs = {}
phlo.store.calcDeps = {}
phlo.store.calcVals = {}
phlo.store.calcTick = false
return
}
const keys = phlo.store.split(prefix)
let ctx = phlo.store.signals
for (let i = 0; i < keys.length - 1; i++) ctx = ctx?.[keys[i]]
if (ctx) delete ctx[keys[keys.length - 1]]
phlo.store.notify(prefix, undefined)
Object.keys(phlo.store.listeners).forEach(path => phlo.store.match(prefix, path) && delete phlo.store.listeners[path])
},
replace(path, value){
const keys = phlo.store.split(path)
let ctx = phlo.store.signals
for (let i = 0; i < keys.length - 1; i++) ctx = ctx?.[keys[i]]
if (ctx) delete ctx[keys[keys.length - 1]]
phlo.store.set(path, value)
},
signal(path, initial){
if (phlo.store.get(path) === undefined) phlo.store.set(path, initial)
return { subscribe: cb => phlo.store.on(path, cb), unsubscribe: cb => phlo.store.off(path, cb) }
},
notify(path, val){
Object.keys(phlo.store.listeners).forEach(dep => {
if (!phlo.store.match(dep, path)) return
const set = phlo.store.listeners[dep]
if (!set) return
const value = dep === path ? val : phlo.store.get(dep)
;[...set].forEach(cb => {
try { cb(value) }
catch(e){ phlo.log('store binding', dep, e) }
})
})
},
match(dep, changed){
if (!dep) return false
if (dep === changed) return true
return changed.startsWith(dep + '.') || changed.startsWith(dep + '[') || dep.startsWith(changed + '.') || dep.startsWith(changed + '[')
},
depsReady(list){
const arr = Array.isArray(list) ? list : (list ? [list] : [])
return arr.every(d => phlo.store.get(d) !== undefined)
},
evalCalc(name){
const fn = phlo.store.calcs[name]
if (!fn) return
let deps = []
let val
try {
const out = fn()
if (Array.isArray(out) && out.length === 2) deps = out[0], val = out[1]
else val = out
}
catch(e){
deps = []
val = undefined
}
const list = Array.isArray(deps) ? deps : (deps ? [deps] : [])
phlo.store.calcDeps[name] = list
if (!phlo.store.depsReady(list)) return
const old = phlo.store.calcVals[name]
if (old !== val){
phlo.store.calcVals[name] = val
const p = `calc.${name}`
phlo.store.setPath(p, val)
phlo.store.notify(p, val)
}
},
recalc(changed){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++){
const name = names[i]
const deps = phlo.store.calcDeps[name] || []
for (let j = 0; j < deps.length; j++){
if (phlo.store.match(deps[j], changed)){
phlo.store.evalCalc(name)
break
}
}
}
},
recalcAll(){
const names = Object.keys(phlo.store.calcs)
for (let i = 0; i < names.length; i++) phlo.store.evalCalc(names[i])
},
schedule(){
if (phlo.store.calcTick) return
phlo.store.calcTick = true
setTimeout(() => {
phlo.store.calcTick = false
phlo.store.recalcAll()
})
},
format(name, value){
const fn = phlo.store.formats[name]
return fn ? fn(value) : value
},
adapters: {
local: {
read: key => { try { return JSON.parse(localStorage.getItem('phlo.' + key)) } catch(e){ return undefined } },
write: (key, value) => { try { localStorage.setItem('phlo.' + key, JSON.stringify(value)) } catch(e){} },
},
session: {
read: key => { try { return JSON.parse(sessionStorage.getItem('phlo.' + key)) } catch(e){ return undefined } },
write: (key, value) => { try { sessionStorage.setItem('phlo.' + key, JSON.stringify(value)) } catch(e){} },
},
},
persist(path, adapter = 'local'){
const store = typeof adapter === 'string' ? phlo.store.adapters[adapter] : adapter
if (!store) return
phlo.store.persists[path] = store
const saved = store.read(path)
if (saved !== undefined && saved !== null) app.mod.store(path, saved)
},
save(changed){
Object.keys(phlo.store.persists).forEach(path => {
if (phlo.store.match(path, changed)) phlo.store.persists[path].write(path, phlo.store.get(path))
})
},
sync(path, options = {}){
phlo.store.syncs[path] = {ws: options.ws ?? true, post: options.post ?? null, delay: options.delay ?? 200}
},
push(changed){
Object.keys(phlo.store.syncs).forEach(path => {
if (!phlo.store.match(path, changed)) return
const sync = phlo.store.syncs[path]
delay('store-' + path, sync.delay, () => {
const value = phlo.store.get(path)
if (sync.ws && app.websocket?.ready) app.websocket.send({sync: {[path]: value}})
if (sync.post) app.post(sync.post, {path, value}, false)
})
})
},
mirror(path, value){
phlo.store.quiet++
try { app.mod.store(path, value) }
finally { phlo.store.quiet-- }
},
proxy(base){
return new Proxy({}, {
get(t, k){
if (typeof k === 'symbol') return undefined
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
const path = base + seg
const v = phlo.store.get(path)
if (v !== undefined && (typeof v !== 'object' || v === null)) return v
return phlo.store.proxy(path)
},
set(t, k, v){
const seg = /^\d+$/.test(k) ? `[${k}]` : (base ? `.${k}` : String(k))
phlo.store.set(base + seg, v)
return true
},
has(t, k){ return phlo.store.get(base + (base ? '.' : '') + String(k)) !== undefined },
ownKeys(){ return Object.keys(phlo.store.get(base) || {}) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
}
}
app.store = phlo.store.proxy('')
app.mod.store = (key, value) => {
if (JSON.stringify(phlo.store.get(key)) === JSON.stringify(value)) return
const walk = (base, obj) => {
if (Array.isArray(obj)) return phlo.store.set(base, obj)
if (typeof obj !== 'object' || obj === null) return phlo.store.set(base, obj)
Object.entries(obj).forEach(([k, v]) => walk(isNaN(k) ? `${base}.${k}` : `${base}[${k}]`, v))
}
walk(key, value)
}
app.mod.sync = (key, value) => phlo.store.mirror(key, value)
phlo.calc = new Proxy({}, {
set(t, k, fn){
if (typeof fn !== 'function') return false
phlo.store.calcs[k] = fn
phlo.store.evalCalc(k)
setTimeout(() => phlo.store.evalCalc(k))
return true
},
get(t, k){ return phlo.store.calcs[k] },
has(t, k){ return k in phlo.store.calcs },
deleteProperty(t, k){
delete phlo.store.calcs[k]
delete phlo.store.calcDeps[k]
delete phlo.store.calcVals[k]
return true
}
})
app.calc = new Proxy({}, {
get(t, k){ return phlo.store.calcVals[k] },
has(t, k){ return k in phlo.store.calcVals },
ownKeys(){ return Object.keys(phlo.store.calcVals) },
getOwnPropertyDescriptor(){ return { enumerable: true, configurable: true } }
})
app.format = (name, fn) => phlo.store.formats[name] = fn
app.persist = (path, adapter) => phlo.store.persist(path, adapter)
app.sync = (path, options) => phlo.store.sync(path, options)
app.push = path => phlo.store.push(path)
app.replace = (path, value) => phlo.store.replace(path, value)
onExist('[data-bind]', el => {
const not = el.dataset.bind.startsWith('!')
const key = not ? el.dataset.bind.slice(1) : el.dataset.bind
const isCalc = key.startsWith('calc.')
const isInput = el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA'
const fromDom = isInput ? el.value : el.textContent
const fromStore = phlo.store.get(key)
const domNonEmpty = (fromDom ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && domNonEmpty && storeEmpty
const format = el.dataset.bindFormat
const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = raw => {
const v = not ? !raw : raw
const s = S(format ? phlo.store.format(format, v) : v)
if (isInput) el.value = s
else el.textContent = s
}
phlo.store.on(key, apply, el)
if (domLeads) phlo.store.set(key, fromDom)
const initial = domLeads ? fromDom : fromStore
apply(initial)
if (!isCalc && isInput) el.oninput = e => phlo.store.set(key, e.target.value)
})
onExist('[data-bind-attr]', el => {
const spec = el.getAttribute('data-bind-attr')
if (!spec) return
const BOOL = new Set(['disabled','checked','hidden','required','readonly','selected','autofocus','multiple'])
let meta = phlo.existing.get(el)
if (!meta || typeof meta !== 'object'){
meta = { exist: true }
phlo.existing.set(el, meta)
}
meta.attr || (meta.attr = {})
meta.attr.cls || (meta.attr.cls = [])
const format = el.dataset.bindFormat
const owned = (el.dataset.bindClass || '').split(/\s+/).filter(Boolean)
spec.split(/\s*,\s*/).filter(Boolean).forEach(pair => {
const m = pair.match(/^\s*([^:]+)\s*:\s*(.+)\s*$/)
if (!m) return
const name = m[1]
const not = m[2].startsWith('!')
const path = not ? m[2].slice(1) : m[2]
const isCalc = path.startsWith('calc.')
const isInputVal = name === 'value' && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')
const domVal =
name === 'text' ? el.textContent :
name === 'html' ? el.innerHTML :
name === 'value' ? el.value :
(name === 'class' ? null : el.getAttribute(name))
const fromStore = phlo.store.get(path)
const domNonEmpty = (domVal ?? '').trim() !== ''
const storeEmpty = fromStore === undefined || (typeof fromStore === 'string' && fromStore.trim() === '')
const domLeads = !isCalc && name !== 'class' && domNonEmpty && storeEmpty
const S = v => v == null ? '' : (typeof v === 'object' ? '' : String(v))
const apply = raw => {
const v = not ? !raw : raw
if (name === 'text') el.textContent = S(format ? phlo.store.format(format, v) : v)
else if (name === 'html') app.mod.inner(el, S(v))
else if (name === 'value') app.mod.value(el, S(format ? phlo.store.format(format, v) : v))
else if (name === 'class'){
const next = Array.isArray(v) ? v : (v && typeof v === 'object') ? Object.keys(v).filter(k => v[k]) : String(v ?? '').split(/\s+/)
const uniq = [...new Set(next.filter(Boolean))]
const prev = [...new Set([...meta.attr.cls, ...owned])]
for (let i = 0; i < prev.length; i++) el.classList.remove(prev[i])
for (let i = 0; i < uniq.length; i++) el.classList.add(uniq[i])
meta.attr.cls = uniq
}
else if (BOOL.has(name)){
const on = !!v
app.mod.attr(el, { [name]: on ? '' : null })
if (name in el) el[name] = on
}
else app.mod.attr(el, { [name]: S(v) })
}
phlo.store.on(path, apply, el)
if (domLeads) phlo.store.set(path, domVal)
const initial = domLeads ? domVal : fromStore
apply(initial)
if (!isCalc && isInputVal) el.oninput = e => phlo.store.set(path, e.target.value)
})
})
onExist('[data-each]', el => {
const path = el.dataset.each
const templates = [...objects('template', el)]
if (!templates.length) return
const key = el.dataset.key ? (el.dataset.key.startsWith('.') ? el.dataset.key : '.' + el.dataset.key) : ''
const pick = el.dataset.eachTemplate ? (el.dataset.eachTemplate.startsWith('.') ? el.dataset.eachTemplate : '.' + el.dataset.eachTemplate) : ''
const fallback = templates.find(t => t.dataset.template === undefined) || templates[0]
const template = index => {
if (!pick) return fallback
const name = String(phlo.store.get(`${path}[${index}]${pick}`) ?? '')
return templates.find(t => t.dataset.template === name) || fallback
}
const rows = new Map
const address = (node, index) => {
const base = `${path}[${index}]`
const bound = [...objects('[data-bind], [data-bind-attr]', node)]
if (node.matches('[data-bind], [data-bind-attr]')) bound.push(node)
bound.forEach(item => {
if (item.dataset.bind !== undefined && item.dataset.eachBind === undefined) item.dataset.eachBind = item.dataset.bind
const attr = item.getAttribute('data-bind-attr')
if (attr !== null && item.dataset.eachBindAttr === undefined) item.dataset.eachBindAttr = attr
if (item.dataset.eachBind !== undefined){
const not = item.dataset.eachBind.startsWith('!') ? '!' : ''
const rel = not ? item.dataset.eachBind.slice(1) : item.dataset.eachBind
item.dataset.bind = not + (rel === '.' ? base : base + rel)
}
if (item.dataset.eachBindAttr !== undefined) item.setAttribute('data-bind-attr', item.dataset.eachBindAttr.replace(/:\s*(!?)\./g, (all, not) => ': ' + not + base + '.'))
})
}
const draw = () => {
const list = phlo.store.get(path)
const items = Array.isArray(list) ? list : (list && typeof list === 'object' ? Object.values(list) : [])
let changed = false
items.forEach((item, index) => {
const id = key ? String(phlo.store.get(`${path}[${index}]${key}`) ?? index) : String(index)
const tpl = template(index)
const row = rows.get(index)
if (row && row.id === id && row.tpl === tpl) return
row?.node.remove()
const node = tpl.content.firstElementChild.cloneNode(true)
address(node, index)
rows.set(index, {id, tpl, node})
el.appendChild(node)
changed = true
})
for (const [index, row] of [...rows]){
if (index < items.length) continue
row.node.remove()
rows.delete(index)
changed = true
}
if (changed) app.update()
}
phlo.store.on(path, draw, el)
draw()
})
onExist('[data-store-post]', el => {
const path = el.dataset.bind || el.dataset.storePath
if (!path) return
phlo.store.sync(path, {ws: el.dataset.storeWs !== undefined, post: el.dataset.storePost, delay: parseInt(el.dataset.storeDelay) || 200})
})
app.updates.push(() => phlo.store.sweep())
addEventListener('popstate', () => phlo.store.reset(phlo.store.scope))object
%template
/phlo/resources/DOM/template.phlo
view
script
line 10
Definieert een functie die een opgegeven sjabloon toepast op elke rij gegevens, waarbij het sjabloon wordt aangeroepen met de waarden uit de rij.
app.mod.template = (template, rows) => rows.forEach(row => templates[template](...Object.values(row)))
const templates = {}object
%timestamps
/phlo/resources/DOM/timestamps.phlo
view
script
line 10
Werk de tekstinhoud van elementen met een 'data-ts'-attribuut bij om de verstreken tijd sinds een tijdstempel in een leesbaar formaat weer te geven, elke seconde vernieuwend.
app.tsBase = {seconds: 60, minutes: 60, hours: 24, days: 7, weeks: 4, months: 13, years: 1}
const tsUpdate = () => (ranges = app.tsLabels && (tsValues = Object.values(app.tsBase)) ? Object.fromEntries(app.tsLabels.map((k, i) => [k, tsValues[i]])) : app.tsBase) && objects('[data-ts]').forEach(el => {
let age = Math.round(Date.now() / 1000) - Number(el.dataset.ts), text = ''
const future = age < 0
if (future) age = -age
for (const [range, multiplier] of Object.entries(ranges)){
if (text) continue
if (age / multiplier < 1.6583) text = `${Math.round(age)} ${range}`
age /= multiplier
}
text ||= `${Math.round(age)} ${Object.keys(ranges).at(-1)}`
text = `${future ? '-' : ''}${text}`
el.innerText === text || (el.innerText = text)
})
setInterval(() => document.hidden || tsUpdate(), 1000)
setTimeout(tsUpdate, 1)object
%toasts
/phlo/resources/DOM/toasts.phlo
view
script
line 11
app.mod.toast = msg => {
obj('#toasts') || app.mod.append('body', '<div id="toasts"></div>')
const toast = document.createElement('div')
toast.textContent = msg
toast.onclick = () => toast.remove()
obj('#toasts').insertAdjacentElement('beforeend', toast)
setTimeout(() => toast.remove(), 4000)
}view
style
line 22
Definieert de CSS-stijlen voor toastmeldingen, die vast in de rechterbovenhoek worden gepositioneerd met specifieke achtergrondkleur, randradius en tekststijl.
#toasts {
position: fixed
right: 10px
top: 5px
z-index: 1001
> * {
background-color: #000A
border-radius: 10px
clear: both
color: white
cursor: zoom-out
float: right
margin-top: 5px
padding: 3px 6px
}
}object
%visible
/phlo/resources/DOM/visible.phlo
view
script
line 11
Stelt een IntersectionObserver in om callback-functies uit te voeren wanneer opgegeven elementen zichtbaar of verborgen worden in het viewport.
phlo.observe = []
phlo.observing = new WeakMap
const onVisible = (els, cbIn, cbOut) => onVisibleIn(els, null, cbIn, cbOut)
const onVisibleIn = (els, root, cbIn, cbOut) => phlo.observe.push({els, root, cbIn, cbOut})
app.updates.push(() => {
const observers = []
phlo.observe.forEach(item => objects(item.els).forEach(el => phlo.observing.has(el) || observers.push({el, root: item.root, cbIn: item.cbIn, cbOut: item.cbOut})))
observers.forEach(item => [phlo.observing.has(item.el) || phlo.observing.set(item.el, 'observe'), (observer = new IntersectionObserver(entries => entries.forEach(entry => entry.isIntersecting ? !item.cbIn && item.cbOut ? [observer.unobserve(entry.target), item.cbOut(entry.target)] : item.cbIn(entry.target) : item.cbIn && item.cbOut && item.cbOut(entry.target)), {root: obj(item.root), threshold: .1})).observe(item.el)])
})object
%websocket
/phlo/resources/DOM/websocket.phlo
view
script
line 11
app.websocket = {
get open(){
app.options.contains('wss') && delay('websocket', app.websocket.retry, () => {
phlo.wss?.close()
phlo.wss = new WebSocket(`wss://${location.host}/${app.websocket.path}`)
phlo.wss.onmessage = e => [{trans, state, ...cmds} = JSON.parse(e.data), apply(cmds, trans, state)]
phlo.wss.onopen = e => [app.websocket.emit('connect', e), phlo.log('🖧 Websocket connected', e), app.websocket.retry = 333]
phlo.wss.onerror = e => [app.websocket.emit('error', e), phlo.log('🖧 Websocket error', e)]
phlo.wss.onclose = e => [app.websocket.emit('close', e), phlo.log('🖧 Websocket close', e), app.websocket.retry && [app.websocket.open, app.websocket.retry *= 3]]
})
},
path: 'websocket',
get ready(){ return phlo.wss?.readyState === 1 },
subs: {},
on(event, cb, el = null){
(app.websocket.subs[event] ??= []).push({cb, el})
event === 'connect' && app.websocket.ready && cb()
return () => app.websocket.off(event, cb)
},
off(event, cb){ app.websocket.subs[event] = (app.websocket.subs[event] || []).filter(sub => sub.cb !== cb) },
emit(event, e){
const single = app.websocket[event]
single && single(e)
app.websocket.subs[event] = (app.websocket.subs[event] || []).filter(sub => !sub.el || sub.el.isConnected)
app.websocket.subs[event].forEach(sub => {
try { sub.cb(e) }
catch(err){ phlo.log('🖧 Websocket ' + event, err) }
})
},
send: data => phlo.wss?.readyState === 1 ? [phlo.log('🖧 app.websocket.send', '\n', data), phlo.wss.send(JSON.stringify(data))] : phlo.error('🖧 Could not send websocket data over closed socket'),
retry: 333,
}
app.websocket.openLaatst bijgewerkt op 07-08-2026