mirror of
https://github.com/rsms/inter.git
synced 2024-11-17 07:47:33 +01:00
562 lines
15 KiB
JavaScript
562 lines
15 KiB
JavaScript
var CtxEdit = (function(){
|
|
|
|
|
|
function getLocalObject(key) {
|
|
let s = sessionStorage.getItem(key)
|
|
if (s) {
|
|
try {
|
|
return JSON.parse(s)
|
|
} catch (e) {
|
|
console.error(
|
|
`failed to parse sessionStorage value "${s}" for key ${key}`,
|
|
err.stack || String(err)
|
|
)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
|
|
function setLocalObject(key, value) {
|
|
let json = JSON.stringify(value)
|
|
sessionStorage.setItem(key, json)
|
|
}
|
|
|
|
|
|
function rmLocalObject(key) {
|
|
sessionStorage.removeItem(key)
|
|
}
|
|
|
|
|
|
class FloatProp {
|
|
constructor(cssProp, unitSuffix) {
|
|
this.cssProp = cssProp
|
|
this.unitSuffix = unitSuffix
|
|
}
|
|
|
|
valueInStyle(s) {
|
|
let v = s[this.cssProp]
|
|
return v !== undefined ? parseFloat(v) : v
|
|
}
|
|
|
|
applyStyle(el, value) {
|
|
el.style[this.cssProp] = value + this.unitSuffix
|
|
}
|
|
}
|
|
|
|
class FontStyleProp {
|
|
valueInStyle(s) {
|
|
let italic = s['font-style'] == 'italic' || s['font-style'].indexOf('oblique') != -1
|
|
let weight = parseFloat(s['font-weight'])
|
|
if (isNaN(weight)) {
|
|
weight = s['font-weight']
|
|
if (weight == 'thin') { return italic ? 'thin-italic' : 'thin' }
|
|
if (weight == 'extra-light') {return italic ? 'extra-light-italic' :'extra-light' }
|
|
if (weight == 'light') { return italic ? 'light-italic' : 'light' }
|
|
if (weight == 'normal') { return italic ? 'italic' : 'regular' }
|
|
if (weight == 'medium') { return italic ? 'medium-italic' : 'medium' }
|
|
if (weight == 'semi-bold') { return italic ? 'semi-bold-italic' : 'semi-bold' }
|
|
if (weight == 'bold') { return italic ? 'bold-italic' : 'bold' }
|
|
if (weight == 'extra-bold') { return italic ? 'extra-bold-italic' : 'extra-bold' }
|
|
} else {
|
|
if (weight <= 150) { return italic ? 'thin-italic' : 'thin' }
|
|
if (weight <= 250) { return italic ? 'extra-light-italic' :'extra-light' }
|
|
if (weight <= 350) { return italic ? 'light-italic' : 'light' }
|
|
if (weight <= 450) { return italic ? 'italic' : 'regular' }
|
|
if (weight <= 550) { return italic ? 'medium-italic' : 'medium' }
|
|
if (weight <= 650) { return italic ? 'semi-bold-italic' : 'semi-bold' }
|
|
if (weight <= 750) { return italic ? 'bold-italic' : 'bold' }
|
|
if (weight <= 850) { return italic ? 'extra-bold-italic' : 'extra-bold' }
|
|
}
|
|
return italic ? 'black-italic' : 'black'
|
|
}
|
|
|
|
applyStyle(el, value) {
|
|
let cl = el.classList
|
|
for (let k of Array.from(cl.values())) {
|
|
if (k.indexOf('font-style-') == 0) {
|
|
cl.remove(k)
|
|
}
|
|
}
|
|
cl.add('font-style-' + value)
|
|
}
|
|
}
|
|
|
|
class LineHeightProp {
|
|
valueInStyle(s) {
|
|
let v = s['line-height']
|
|
if (v === undefined) {
|
|
return 1.0
|
|
}
|
|
if (v.lastIndexOf('px') == v.length - 2) {
|
|
// compute
|
|
return parseFloat(
|
|
(parseFloat(v) / parseFloat(s['font-size'])).toFixed(3)
|
|
)
|
|
}
|
|
v = parseFloat(v)
|
|
return isNaN(v) ? 1.0 : v
|
|
}
|
|
|
|
applyStyle(el, value) {
|
|
el.style['line-height'] = String(value)
|
|
}
|
|
}
|
|
|
|
class TrackingProp {
|
|
valueInStyle(s) {
|
|
let v = s['letter-spacing']
|
|
if (v === undefined) {
|
|
return 0
|
|
}
|
|
if (v.lastIndexOf('px') == v.length - 2) {
|
|
// compute
|
|
return parseFloat(
|
|
(parseFloat(v) / parseFloat(s['font-size'])).toFixed(3)
|
|
)
|
|
}
|
|
v = parseFloat(v)
|
|
return isNaN(v) ? 0 : v
|
|
}
|
|
|
|
applyStyle(el, value) {
|
|
el.style['letter-spacing'] = value.toFixed(3) + 'em'
|
|
}
|
|
}
|
|
|
|
const Props = {
|
|
size: new FloatProp('font-size', 'px'),
|
|
tracking: new TrackingProp(),
|
|
lineHeight: new LineHeightProp(),
|
|
style: new FontStyleProp(),
|
|
}
|
|
|
|
function valuesFromStyle(s) {
|
|
let values = {}
|
|
for (let name in Props) {
|
|
let p = Props[name]
|
|
values[name] = p.valueInStyle(s)
|
|
}
|
|
return values
|
|
}
|
|
|
|
|
|
class Editable {
|
|
constructor(el, key) {
|
|
this.el = el
|
|
this.key = key
|
|
this.defaultValues = valuesFromStyle(getComputedStyle(this.el))
|
|
// console.log('default values:', this.defaultValues)
|
|
this.values = Object.assign({}, this.defaultValues)
|
|
this.explicitTracking = false
|
|
this.explicitTrackingKey = this.key + ":etracking"
|
|
this.loadValues()
|
|
this.updateSizeDependantProps()
|
|
}
|
|
|
|
resetValues() {
|
|
this.values = Object.assign({}, this.defaultValues)
|
|
let style = this.el.style
|
|
for (let name in this.values) {
|
|
Props[name].applyStyle(this.el, this.values[name])
|
|
}
|
|
rmLocalObject(this.key)
|
|
rmLocalObject(this.explicitTrackingKey)
|
|
this.explicitTracking = false
|
|
this.updateSizeDependantProps()
|
|
}
|
|
|
|
setExplicitTracking(explicitTracking) {
|
|
if (this.explicitTracking !== explicitTracking) {
|
|
this.explicitTracking = explicitTracking
|
|
if (!this.explicitTracking) {
|
|
this.updateSizeDependantProps()
|
|
}
|
|
}
|
|
}
|
|
|
|
setValue(name, value) {
|
|
this.values[name] = value
|
|
Props[name].applyStyle(this.el, value)
|
|
if (name == 'size') {
|
|
this.updateSizeDependantProps()
|
|
}
|
|
}
|
|
|
|
updateSizeDependantProps() {
|
|
let size = this.values.size
|
|
|
|
// dynamic tracking
|
|
if (!this.explicitTracking) {
|
|
this.setValue('tracking', InterDynamicTracking(size))
|
|
}
|
|
|
|
// left indent
|
|
// TODO: Consider making this part of dynamic metrics.
|
|
let leftMargin = size / -16
|
|
if (leftMargin == 0) {
|
|
this.el.style.marginLeft = null
|
|
} else {
|
|
this.el.style.marginLeft = leftMargin.toFixed(1) + 'px'
|
|
}
|
|
}
|
|
|
|
loadValues() {
|
|
let values = getLocalObject(this.key)
|
|
if (values && typeof values == 'object') {
|
|
for (let name in values) {
|
|
if (name in this.values) {
|
|
let value = values[name]
|
|
this.values[name] = value
|
|
Props[name].applyStyle(this.el, value)
|
|
} else if (console.warn) {
|
|
console.warn(`Editable.loadValues ignoring unknown "${name}"`)
|
|
}
|
|
}
|
|
// console.log(`loaded values for ${this}:`, values)
|
|
}
|
|
let etr = getLocalObject(this.explicitTrackingKey)
|
|
this.explicitTracking = false
|
|
if (etr) {
|
|
this.explicitTracking = true
|
|
}
|
|
}
|
|
|
|
isDefaultValues() {
|
|
for (let k in this.values) {
|
|
if (this.values[k] !== this.defaultValues[k]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
saveValues() {
|
|
if (this.isDefaultValues()) {
|
|
rmLocalObject(this.key)
|
|
rmLocalObject(this.explicitTrackingKey)
|
|
} else {
|
|
setLocalObject(this.key, this.values)
|
|
setLocalObject(this.explicitTrackingKey, this.explicitTracking ? "1" : "0")
|
|
}
|
|
// console.log(`saved values for ${this}`)
|
|
}
|
|
|
|
toString() {
|
|
return `Editable(${this.key})`
|
|
}
|
|
}
|
|
|
|
|
|
var supportsFocusTrick = (u =>
|
|
u.indexOf('Firefox/') == -1
|
|
)(navigator.userAgent)
|
|
|
|
|
|
class CtxEdit {
|
|
constructor() {
|
|
this.bindings = new Bindings()
|
|
this.keyPrefix = 'ctxedit:' + document.location.pathname + ':'
|
|
this.editables = new Map()
|
|
this.ui = $('#ctxedit-ui')
|
|
this.currEditable = null
|
|
this._saveValuesTimer = null
|
|
this.isChangingBindings = true
|
|
this.bindings = new Bindings()
|
|
this.initBindings()
|
|
this.initUI()
|
|
this.addAllEditables()
|
|
this.isChangingBindings = false
|
|
this.preloadFonts()
|
|
|
|
if (supportsFocusTrick) {
|
|
this.ui.addEventListener('focus', ev => {
|
|
if (this.currEditable) {
|
|
ev.preventDefault()
|
|
ev.stopImmediatePropagation()
|
|
this.currEditable.el.focus() // breaks Firefox
|
|
}
|
|
}, {capture:true, passive:false})
|
|
}
|
|
}
|
|
|
|
initUI() {
|
|
$('.reset-button', this.ui).addEventListener('click', ev => this.reset())
|
|
$('.dismiss-button', this.ui).addEventListener('click', ev => this.stopEditing())
|
|
this.initRangeSliders()
|
|
}
|
|
|
|
initRangeSliders() {
|
|
this._sliderTimers = new Map()
|
|
$$('input[type="range"]', this.ui).forEach(input => {
|
|
var binding = this.bindings.getBinding(input.dataset.binding)
|
|
|
|
// create and hook up value tip
|
|
let valtip = document.createElement('div')
|
|
let valtipval = document.createElement('div')
|
|
let valtipcallout = document.createElement('div')
|
|
valtip.className = 'slider-value-tip'
|
|
valtipval.className = 'value'
|
|
valtipcallout.className = 'callout'
|
|
valtipval.innerText = '0'
|
|
valtip.appendChild(valtipval)
|
|
valtip.appendChild(valtipcallout)
|
|
binding.addOutput(valtipval)
|
|
document.body.appendChild(valtip)
|
|
|
|
let inputBounds = {}
|
|
let min = parseFloat(input.getAttribute('min'))
|
|
let max = parseFloat(input.getAttribute('max'))
|
|
if (isNaN(min)) {
|
|
min = 0
|
|
}
|
|
if (isNaN(max)) {
|
|
max = 1
|
|
}
|
|
const sliderThumbWidth = 12
|
|
const valtipYOffset = 14
|
|
|
|
let updateValTipXPos = () => {
|
|
let r = (binding.value - min) / (max - min)
|
|
let sliderWidth = inputBounds.width - sliderThumbWidth
|
|
let x = ((inputBounds.x + (sliderThumbWidth / 2)) + (sliderWidth * r)) - (valtip.clientWidth / 2)
|
|
valtip.style.left = x + 'px'
|
|
}
|
|
|
|
binding.addListener(updateValTipXPos)
|
|
|
|
let shownCounter = 0
|
|
let showValTip = () => {
|
|
if (++shownCounter == 1) {
|
|
valtip.classList.add('visible')
|
|
inputBounds = input.getBoundingClientRect()
|
|
valtip.style.top = (inputBounds.y - valtip.clientHeight + valtipYOffset) + 'px'
|
|
updateValTipXPos()
|
|
}
|
|
}
|
|
let hideValTip = () => {
|
|
if (--shownCounter == 0) {
|
|
valtip.classList.remove('visible')
|
|
}
|
|
}
|
|
|
|
input.addEventListener('pointerdown', showValTip)
|
|
input.addEventListener('pointerup', hideValTip)
|
|
input.addEventListener('pointercancel', hideValTip)
|
|
|
|
let timer = null
|
|
input.addEventListener('input', ev => {
|
|
if (timer === null) {
|
|
showValTip()
|
|
} else {
|
|
clearTimeout(timer)
|
|
}
|
|
timer = setTimeout(() => {
|
|
timer = null
|
|
hideValTip()
|
|
}, 400)
|
|
})
|
|
})
|
|
}
|
|
|
|
initBindings() {
|
|
let b = this.bindings
|
|
|
|
// let updateTracking = fontSize => {
|
|
// if (!this.currEditable.explicitTracking) {
|
|
// var tracking = InterDynamicTracking(fontSize)
|
|
// this.isChangingBindings = true
|
|
// b.setValue('tracking', tracking)
|
|
// this.isChangingBindings = false
|
|
// }
|
|
// }
|
|
|
|
b.configure('tracking', 0, 'float', tracking => {
|
|
if (!this.isChangingBindings && !this.currEditable.explicitTracking) {
|
|
// console.log('enabled explicit tracking')
|
|
this.currEditable.setExplicitTracking(true)
|
|
this.setNeedsSaveValues()
|
|
}
|
|
})
|
|
b.setFormatter('tracking', v => v.toFixed(3))
|
|
|
|
b.configure('size', 0, 'float', size => {
|
|
let ed = this.currEditable
|
|
if (ed) {
|
|
setTimeout(() => {
|
|
// HERE BE DRAGONS! Feedback loop from Editable
|
|
if (!ed.explicitTracking) {
|
|
this.isChangingBindings = true
|
|
b.setValue('tracking', ed.values.tracking)
|
|
this.isChangingBindings = false
|
|
}
|
|
}, 10)
|
|
}
|
|
})
|
|
|
|
b.configure('lineHeight', 1, 'float')
|
|
|
|
b.bindAllInputs($$('.control input', this.ui))
|
|
b.bindAllInputs($$('.control select', this.ui))
|
|
|
|
$('.control input[data-binding="tracking"]').addEventListener("dblclick", ev => {
|
|
let ed = this.currEditable
|
|
setTimeout(() => {
|
|
ed.setExplicitTracking(false)
|
|
this.setNeedsSaveValues()
|
|
this.isChangingBindings = true
|
|
b.setValue('tracking', ed.values.tracking)
|
|
this.isChangingBindings = false
|
|
}, 50)
|
|
})
|
|
|
|
for (let binding of b.allBindings()) {
|
|
binding.addListener(() => this.bindingChanged(binding))
|
|
}
|
|
}
|
|
|
|
preloadFonts() {
|
|
// Note: This has no effect on systems supporting variable fonts.
|
|
[
|
|
"regular",
|
|
"italic",
|
|
"medium",
|
|
"medium-italic",
|
|
"semi-bold",
|
|
"semi-bold-italic",
|
|
"bold",
|
|
"bold-italic",
|
|
"extra-bold",
|
|
"extra-bold-italic",
|
|
"black",
|
|
"black-italic",
|
|
].forEach(style => {
|
|
let e = document.createElement('div')
|
|
e.className = 'font-preload font-style-' + style
|
|
e.innerText = 'a'
|
|
document.body.appendChild(e)
|
|
})
|
|
}
|
|
|
|
bindingChanged(binding) {
|
|
if (this.isChangingBindings) {
|
|
// Note: this.isChangingBindings is true when binding values are
|
|
// changed internally, in which case we do nothing here.
|
|
return
|
|
}
|
|
if (this.currEditable) {
|
|
this.currEditable.setValue(binding.name, binding.value)
|
|
}
|
|
this.setNeedsSaveValues()
|
|
}
|
|
|
|
reset() {
|
|
for (let ed of this.editables.values()) {
|
|
ed.resetValues()
|
|
}
|
|
this.updateBindingValues()
|
|
}
|
|
|
|
updateBindingValues() {
|
|
if (this.currEditable) {
|
|
this.isChangingBindings = true
|
|
this.bindings.setValues(this.currEditable.values)
|
|
this.isChangingBindings = false
|
|
}
|
|
}
|
|
|
|
saveValues() {
|
|
if (this._saveValuesTimer !== null) {
|
|
clearTimeout(this._saveValuesTimer)
|
|
this._saveValuesTimer = null
|
|
}
|
|
if (this.currEditable) {
|
|
this.currEditable.saveValues()
|
|
}
|
|
}
|
|
|
|
setNeedsSaveValues() {
|
|
if (this._saveValuesTimer !== null) {
|
|
clearTimeout(this._saveValuesTimer)
|
|
}
|
|
this._saveValuesTimer = setTimeout(() => this.saveValues(), 300)
|
|
}
|
|
|
|
setCurrEditable(ed) {
|
|
if (this._saveValuesTimer !== null &&
|
|
this.currEditable &&
|
|
!this.isChangingBindings)
|
|
{
|
|
this.saveValues()
|
|
}
|
|
this.currEditable = ed
|
|
this.updateBindingValues()
|
|
if (this.currEditable) {
|
|
this.showUI()
|
|
} else {
|
|
this.hideUI()
|
|
}
|
|
}
|
|
|
|
onEditableReceivedFocus(ed) {
|
|
// console.log(`onEditableReceivedFocus ${ed}`)
|
|
clearTimeout(this._deselectTimer)
|
|
this.setCurrEditable(ed)
|
|
}
|
|
|
|
onEditableLostFocus(ed) {
|
|
// console.log(`onEditableLostFocus ${ed}`)
|
|
// this.setCurrEditable(null)
|
|
if (supportsFocusTrick) {
|
|
this._deselectTimer = setTimeout(() => this.setCurrEditable(null), 10)
|
|
}
|
|
}
|
|
|
|
showUI() {
|
|
this.ui.classList.add('visible')
|
|
}
|
|
|
|
hideUI() {
|
|
this.ui.classList.remove('visible')
|
|
}
|
|
|
|
stopEditing() {
|
|
if (this.currEditable) {
|
|
this.currEditable.el.blur()
|
|
this.setCurrEditable(null)
|
|
}
|
|
}
|
|
|
|
addAllEditables() {
|
|
for (let el of $$('[data-ctxedit]')) {
|
|
this.addEditable(el)
|
|
}
|
|
}
|
|
|
|
addEditable(el) {
|
|
let key = this.keyPrefix + el.dataset.ctxedit
|
|
let existing = this.editables.get(key)
|
|
if (existing) {
|
|
throw new Error(`duplicate editable ${key}`)
|
|
}
|
|
let ed = new Editable(el, key)
|
|
this.editables.set(key, ed)
|
|
this.initEditable(ed)
|
|
// this.showUI() // XXX
|
|
}
|
|
|
|
initEditable(ed) {
|
|
// filter paste
|
|
ed.el.addEventListener('paste', ev => {
|
|
ev.preventDefault()
|
|
let text = ev.clipboardData.getData("text/plain")
|
|
document.execCommand("insertHTML", false, text)
|
|
}, {capture:true,passive:false})
|
|
|
|
ed.el.addEventListener('focus', ev => this.onEditableReceivedFocus(ed))
|
|
ed.el.addEventListener('blur', ev => this.onEditableLostFocus(ed))
|
|
}
|
|
}
|
|
|
|
return CtxEdit
|
|
})();
|