feat: added scale buttons
This commit is contained in:
parent
a96734c394
commit
53c25c9da3
4 changed files with 391 additions and 22 deletions
|
|
@ -125,12 +125,18 @@
|
|||
<style>
|
||||
.measurement-toggles {
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: var(--space-tight);
|
||||
padding: var(--space-tight) 0;
|
||||
margin-bottom: var(--space-element);
|
||||
}
|
||||
.measurement-toggle-btn {
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-tight);
|
||||
align-items: center;
|
||||
}
|
||||
.measurement-toggle-btn, .scale-btn {
|
||||
font-size: var(--font-size-small);
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--color-border-default);
|
||||
|
|
@ -140,11 +146,34 @@
|
|||
cursor: pointer;
|
||||
transition: background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.measurement-toggle-btn:hover {
|
||||
.measurement-toggle-btn:hover, .scale-btn:hover {
|
||||
border-color: var(--color-border-focus);
|
||||
background: var(--color-text-link);
|
||||
color: var(--color-bg-page);
|
||||
}
|
||||
.scale-btn.active {
|
||||
background: var(--color-text-link);
|
||||
color: var(--color-bg-page);
|
||||
border-color: var(--color-text-link);
|
||||
}
|
||||
.scale-label {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.scale-input {
|
||||
font-size: var(--font-size-small);
|
||||
padding: 4px 8px;
|
||||
width: 5em;
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-inline);
|
||||
background: var(--color-bg-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.scale-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-focus);
|
||||
}
|
||||
.measurement {
|
||||
border-bottom: 1px dotted var(--color-text-muted);
|
||||
cursor: default;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
/**
|
||||
* Client-side measurement toggle.
|
||||
* Default: metric units, collapsed times (rendered at build time).
|
||||
* Each measurement type gets its own toggle button.
|
||||
* Client-side measurement toggle + recipe scaling.
|
||||
* Default: metric units, collapsed times, 1x scale.
|
||||
*
|
||||
* Each measurement span has data-default and data-alt attributes
|
||||
* pre-computed at build time. This script just swaps between them
|
||||
* per measurement type.
|
||||
* Each measurement span has:
|
||||
* data-default / data-alt — pre-computed metric/imperial text (1x)
|
||||
* data-scalable — JSON with base values in g/ml for scaling
|
||||
* data-measurement-type — weight|volume|temperature|dimension|time
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Track which types are showing alt text
|
||||
// ─── State ──────────────────────────────────────────────
|
||||
|
||||
var showingAlt = {};
|
||||
var scale = 1;
|
||||
|
||||
var TYPE_LABELS = {
|
||||
temperature: { toAlt: '°F', toDefault: '°C' },
|
||||
|
|
@ -22,33 +24,201 @@
|
|||
time: { toAlt: 'hr+min', toDefault: 'min' },
|
||||
};
|
||||
|
||||
function toggleType(type) {
|
||||
showingAlt[type] = !showingAlt[type];
|
||||
var isAlt = showingAlt[type];
|
||||
// ─── Formatting (mirrors plugin logic) ──────────────────
|
||||
|
||||
// Update all spans of this type
|
||||
var spans = document.querySelectorAll('span.measurement[data-measurement-type="' + type + '"]');
|
||||
var NO_SPACE = { 'g':1, 'kg':1, 'ml':1, '°C':1, '°F':1, 'cm':1, 'mm':1 };
|
||||
|
||||
function formatNumber(n) {
|
||||
if (Number.isInteger(n) || Math.abs(n - Math.round(n)) < 0.001) {
|
||||
return Math.round(n).toString();
|
||||
}
|
||||
var decimals = Math.abs(n) >= 10 ? 1 : 2;
|
||||
return n.toFixed(decimals).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function unitLabel(unit, plural) {
|
||||
var labels = {
|
||||
'g':'g', 'kg':'kg', 'oz':'oz', 'lb':'lb',
|
||||
'cup': plural ? 'cups' : 'cup',
|
||||
'tablespoon':'tbsp', 'teaspoon':'tsp',
|
||||
'ml':'ml', 'L':'L',
|
||||
'quart': plural ? 'quarts' : 'quart',
|
||||
'gallon': plural ? 'gallons' : 'gallon',
|
||||
'pint': plural ? 'pints' : 'pint',
|
||||
'fl oz':'fl oz',
|
||||
};
|
||||
return labels[unit] || unit;
|
||||
}
|
||||
|
||||
function formatVU(value, unit) {
|
||||
var label = unitLabel(unit, value !== 1);
|
||||
var space = NO_SPACE[unit] ? '' : ' ';
|
||||
return formatNumber(value) + space + label;
|
||||
}
|
||||
|
||||
// ─── Smart Unit Selection ───────────────────────────────
|
||||
|
||||
function smartMetricWeight(g) {
|
||||
return g >= 1000 ? { v: g / 1000, u: 'kg' } : { v: g, u: 'g' };
|
||||
}
|
||||
|
||||
function smartImperialWeight(oz) {
|
||||
return oz >= 16 ? { v: oz / 16, u: 'lb' } : { v: oz, u: 'oz' };
|
||||
}
|
||||
|
||||
function smartMetricVolume(ml) {
|
||||
return ml >= 1000 ? { v: ml / 1000, u: 'L' } : { v: ml, u: 'ml' };
|
||||
}
|
||||
|
||||
function smartImperialVolume(ml) {
|
||||
if (ml >= 236.588) return { v: ml / 236.588, u: 'cup' };
|
||||
if (ml >= 14.787) return { v: ml / 14.787, u: 'tablespoon' };
|
||||
return { v: ml / 4.929, u: 'teaspoon' };
|
||||
}
|
||||
|
||||
// ─── Scale Computation ──────────────────────────────────
|
||||
|
||||
function computeScaledText(data, scaleFactor, imperial) {
|
||||
var baseVal = data.base;
|
||||
var isRange = Array.isArray(baseVal);
|
||||
var prefix = data.approx ? '~' : '';
|
||||
|
||||
if (data.type === 'weight') {
|
||||
if (isRange) {
|
||||
var lo = baseVal[0] * scaleFactor;
|
||||
var hi = baseVal[1] * scaleFactor;
|
||||
var sLo, sHi;
|
||||
if (imperial) {
|
||||
sLo = smartImperialWeight(lo / 28.3495);
|
||||
sHi = smartImperialWeight(hi / 28.3495);
|
||||
} else {
|
||||
sLo = smartMetricWeight(lo);
|
||||
sHi = smartMetricWeight(hi);
|
||||
}
|
||||
if (sLo.u === sHi.u) {
|
||||
var space = NO_SPACE[sLo.u] ? '' : ' ';
|
||||
return prefix + formatNumber(sLo.v) + '-' + formatNumber(sHi.v) + space + unitLabel(sLo.u, sHi.v !== 1);
|
||||
}
|
||||
return prefix + formatVU(sLo.v, sLo.u) + '-' + formatVU(sHi.v, sHi.u);
|
||||
}
|
||||
var g = baseVal * scaleFactor;
|
||||
var s;
|
||||
if (imperial) {
|
||||
s = smartImperialWeight(g / 28.3495);
|
||||
} else {
|
||||
s = smartMetricWeight(g);
|
||||
}
|
||||
return prefix + formatVU(s.v, s.u);
|
||||
}
|
||||
|
||||
if (data.type === 'volume') {
|
||||
if (isRange) {
|
||||
var lo = baseVal[0] * scaleFactor;
|
||||
var hi = baseVal[1] * scaleFactor;
|
||||
var sLo, sHi;
|
||||
if (imperial) {
|
||||
sLo = smartImperialVolume(lo);
|
||||
sHi = smartImperialVolume(hi);
|
||||
} else {
|
||||
sLo = smartMetricVolume(lo);
|
||||
sHi = smartMetricVolume(hi);
|
||||
}
|
||||
if (sLo.u === sHi.u) {
|
||||
var space = NO_SPACE[sLo.u] ? '' : ' ';
|
||||
return prefix + formatNumber(sLo.v) + '-' + formatNumber(sHi.v) + space + unitLabel(sLo.u, sHi.v !== 1);
|
||||
}
|
||||
return prefix + formatVU(sLo.v, sLo.u) + '-' + formatVU(sHi.v, sHi.u);
|
||||
}
|
||||
var ml = baseVal * scaleFactor;
|
||||
var s;
|
||||
if (imperial) {
|
||||
s = smartImperialVolume(ml);
|
||||
} else {
|
||||
s = smartMetricVolume(ml);
|
||||
}
|
||||
return prefix + formatVU(s.v, s.u);
|
||||
}
|
||||
|
||||
if (data.type === 'count') {
|
||||
if (isRange) {
|
||||
var lo = baseVal[0] * scaleFactor;
|
||||
var hi = baseVal[1] * scaleFactor;
|
||||
return prefix + formatNumber(lo) + '-' + formatNumber(hi);
|
||||
}
|
||||
return prefix + formatNumber(baseVal * scaleFactor);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Update All Measurements ────────────────────────────
|
||||
|
||||
function updateAll() {
|
||||
var spans = document.querySelectorAll('span.measurement');
|
||||
spans.forEach(function (span) {
|
||||
var text = isAlt
|
||||
var type = span.getAttribute('data-measurement-type');
|
||||
var imperial = !!showingAlt[type];
|
||||
var scalableRaw = span.getAttribute('data-scalable');
|
||||
|
||||
if (scalableRaw && scale !== 1) {
|
||||
// Use scaling computation
|
||||
try {
|
||||
var data = JSON.parse(scalableRaw);
|
||||
var text = computeScaledText(data, scale, imperial);
|
||||
if (text) { span.textContent = text; return; }
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// No scaling or not scalable — use pre-computed text
|
||||
var text = imperial
|
||||
? span.getAttribute('data-alt')
|
||||
: span.getAttribute('data-default');
|
||||
if (text) span.textContent = text;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Unit Toggle ────────────────────────────────────────
|
||||
|
||||
function toggleType(type) {
|
||||
showingAlt[type] = !showingAlt[type];
|
||||
updateAll();
|
||||
|
||||
// Update button text
|
||||
var btn = document.querySelector('.measurement-toggle-btn[data-toggle-type="' + type + '"]');
|
||||
if (btn) {
|
||||
var labels = TYPE_LABELS[type];
|
||||
btn.textContent = isAlt ? labels.toAlt : labels.toDefault;
|
||||
btn.textContent = showingAlt[type] ? labels.toAlt : labels.toDefault;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scale Toggle ───────────────────────────────────────
|
||||
|
||||
function setScale(newScale) {
|
||||
scale = newScale;
|
||||
updateAll();
|
||||
updateScaleButtons();
|
||||
}
|
||||
|
||||
function updateScaleButtons() {
|
||||
var btns = document.querySelectorAll('.scale-btn');
|
||||
btns.forEach(function (btn) {
|
||||
var val = parseFloat(btn.getAttribute('data-scale'));
|
||||
if (val === scale) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Init ───────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
var container = document.querySelector('.measurement-toggles');
|
||||
if (!container) return;
|
||||
|
||||
// Detect which types exist and have toggleable content
|
||||
// Detect which types have toggleable content
|
||||
var typeHasToggle = {};
|
||||
var hasScalable = false;
|
||||
var spans = document.querySelectorAll('span.measurement');
|
||||
spans.forEach(function (span) {
|
||||
var type = span.getAttribute('data-measurement-type');
|
||||
|
|
@ -56,11 +226,16 @@
|
|||
var def = span.getAttribute('data-default');
|
||||
var alt = span.getAttribute('data-alt');
|
||||
if (def !== alt) typeHasToggle[type] = true;
|
||||
if (span.getAttribute('data-scalable')) hasScalable = true;
|
||||
});
|
||||
|
||||
var typeOrder = ['temperature', 'weight', 'volume', 'dimension', 'time'];
|
||||
var hasAny = false;
|
||||
|
||||
// Unit toggle buttons
|
||||
var unitRow = document.createElement('div');
|
||||
unitRow.className = 'toggle-row';
|
||||
|
||||
var typeOrder = ['temperature', 'weight', 'volume', 'dimension', 'time'];
|
||||
typeOrder.forEach(function (type) {
|
||||
if (!typeHasToggle[type]) return;
|
||||
hasAny = true;
|
||||
|
|
@ -71,9 +246,55 @@
|
|||
btn.setAttribute('data-toggle-type', type);
|
||||
btn.textContent = TYPE_LABELS[type].toDefault;
|
||||
btn.addEventListener('click', function () { toggleType(type); });
|
||||
container.appendChild(btn);
|
||||
unitRow.appendChild(btn);
|
||||
});
|
||||
|
||||
if (hasAny) {
|
||||
container.appendChild(unitRow);
|
||||
}
|
||||
|
||||
// Scale buttons
|
||||
if (hasScalable) {
|
||||
hasAny = true;
|
||||
var scaleRow = document.createElement('div');
|
||||
scaleRow.className = 'toggle-row';
|
||||
|
||||
var scaleLabel = document.createElement('span');
|
||||
scaleLabel.className = 'scale-label';
|
||||
scaleLabel.textContent = 'Scale:';
|
||||
scaleRow.appendChild(scaleLabel);
|
||||
|
||||
var scales = [0.5, 1, 2, 3];
|
||||
scales.forEach(function (s) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'scale-btn' + (s === 1 ? ' active' : '');
|
||||
btn.setAttribute('data-scale', s);
|
||||
btn.textContent = s + 'x';
|
||||
btn.addEventListener('click', function () {
|
||||
setScale(s);
|
||||
var input = document.querySelector('.scale-input');
|
||||
if (input) input.value = '';
|
||||
});
|
||||
scaleRow.appendChild(btn);
|
||||
});
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'scale-input';
|
||||
input.placeholder = 'Custom';
|
||||
input.min = '0.1';
|
||||
input.step = '0.1';
|
||||
input.addEventListener('input', function () {
|
||||
var val = parseFloat(input.value);
|
||||
if (val > 0 && !isNaN(val)) {
|
||||
setScale(val);
|
||||
}
|
||||
});
|
||||
scaleRow.appendChild(input);
|
||||
|
||||
container.appendChild(scaleRow);
|
||||
}
|
||||
|
||||
if (hasAny) {
|
||||
container.style.display = 'flex';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -353,7 +353,7 @@ function findVolumes(text) {
|
|||
const results = [];
|
||||
|
||||
const volumeRe = new RegExp(
|
||||
`(${AMOUNT})\\s+(${VOLUME_UNIT})\\b`,
|
||||
`(${AMOUNT})\\s*(${VOLUME_UNIT})\\b`,
|
||||
'gi'
|
||||
);
|
||||
|
||||
|
|
@ -407,6 +407,38 @@ function findTimes(text) {
|
|||
return results;
|
||||
}
|
||||
|
||||
// ─── Count Matcher ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find bare numeric counts in text (no unit attached).
|
||||
* These represent ingredient quantities like "eggs 3" or "biscuits 20-24".
|
||||
* Overlap with unit-bearing matches is resolved by deduplication (longer wins).
|
||||
*/
|
||||
function findCounts(text) {
|
||||
const results = [];
|
||||
|
||||
const countRe = new RegExp(
|
||||
`(${AMOUNT})(?=\\s*$|\\s*,|\\s*\\)|\\s*\\]|\\s*;|\\s+[^\\dxX~])`,
|
||||
'g'
|
||||
);
|
||||
|
||||
let m;
|
||||
while ((m = countRe.exec(text)) !== null) {
|
||||
const amount = parseAmount(m[1]);
|
||||
results.push({
|
||||
match: m[1],
|
||||
index: m.index,
|
||||
type: 'count',
|
||||
amount,
|
||||
unit: null,
|
||||
approximate: typeof amount.approximate === 'boolean' ? amount.approximate : false,
|
||||
alt: null,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Main Matcher ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -424,6 +456,7 @@ function findAllMeasurements(text) {
|
|||
...findWeights(text),
|
||||
...findVolumes(text),
|
||||
...findTimes(text),
|
||||
...findCounts(text),
|
||||
];
|
||||
|
||||
// Sort by position
|
||||
|
|
@ -466,6 +499,7 @@ module.exports = {
|
|||
findWeights,
|
||||
findVolumes,
|
||||
findTimes,
|
||||
findCounts,
|
||||
|
||||
// Parsing utilities (exported for testing)
|
||||
parseAmount,
|
||||
|
|
|
|||
|
|
@ -136,6 +136,70 @@ function toImperialValue(value, unit) {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Base Unit Conversion (for scaling) ─────────────────
|
||||
|
||||
function toBaseGrams(value, unit) {
|
||||
switch (unit) {
|
||||
case 'g': return value;
|
||||
case 'kg': return value * 1000;
|
||||
case 'oz': return value * 28.3495;
|
||||
case 'lb': return value * 453.592;
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
function toBaseMl(value, unit) {
|
||||
switch (unit) {
|
||||
case 'ml': return value;
|
||||
case 'L': return value * 1000;
|
||||
case 'cup': return value * 236.588;
|
||||
case 'tablespoon': return value * 14.787;
|
||||
case 'teaspoon': return value * 4.929;
|
||||
case 'quart': return value * 946.353;
|
||||
case 'gallon': return value * 3785.41;
|
||||
case 'pint': return value * 473.176;
|
||||
case 'fl oz': return value * 29.5735;
|
||||
default: return value;
|
||||
}
|
||||
}
|
||||
|
||||
function computeScaleData(measurement) {
|
||||
const { type, amount, unit, approximate } = measurement;
|
||||
if (unit === 'parts by volume' || unit === 'parts by weight') return null;
|
||||
if (type !== 'weight' && type !== 'volume' && type !== 'count') return null;
|
||||
|
||||
if (type === 'count') {
|
||||
if (amount.min !== undefined) {
|
||||
return {
|
||||
base: [amount.min.value, amount.max.value],
|
||||
type: 'count',
|
||||
approx: approximate || amount.min.approximate || false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
base: amount.value,
|
||||
type: 'count',
|
||||
approx: approximate || false,
|
||||
};
|
||||
}
|
||||
|
||||
const toBase = type === 'weight' ? toBaseGrams : toBaseMl;
|
||||
|
||||
if (amount.min !== undefined) {
|
||||
return {
|
||||
base: [toBase(amount.min.value, unit), toBase(amount.max.value, unit)],
|
||||
type: type,
|
||||
approx: approximate || amount.min.approximate || false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
base: toBase(amount.value, unit),
|
||||
type: type,
|
||||
approx: approximate || false,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Time Formatting ────────────────────────────────────
|
||||
|
||||
function toMinutes(value, unit) {
|
||||
|
|
@ -183,6 +247,12 @@ function generateTexts(measurement) {
|
|||
return { defaultText: text, altText: text };
|
||||
}
|
||||
|
||||
// Bare counts — just display the number, no conversion
|
||||
if (type === 'count') {
|
||||
const text = formatCountText(amount, approximate);
|
||||
return { defaultText: text, altText: text };
|
||||
}
|
||||
|
||||
if (type === 'time') {
|
||||
return generateTimeTexts(amount, unit, approximate);
|
||||
}
|
||||
|
|
@ -190,6 +260,14 @@ function generateTexts(measurement) {
|
|||
return generateUnitTexts(amount, unit, approximate, type);
|
||||
}
|
||||
|
||||
function formatCountText(amount, approximate) {
|
||||
const prefix = approximate ? '~' : '';
|
||||
if (amount.min !== undefined) {
|
||||
return prefix + formatNumber(amount.min.value) + '-' + formatNumber(amount.max.value);
|
||||
}
|
||||
return prefix + formatNumber(amount.value);
|
||||
}
|
||||
|
||||
function generateTimeTexts(amount, unit, approximate) {
|
||||
if (amount.min !== undefined) {
|
||||
const prefix = amount.min.approximate ? '~' : '';
|
||||
|
|
@ -306,6 +384,13 @@ function replaceMeasurementsInToken(token, Token) {
|
|||
open.attrSet('data-default', defaultText);
|
||||
open.attrSet('data-alt', altText);
|
||||
open.attrSet('title', m.match);
|
||||
|
||||
// Add scaling data for weight/volume
|
||||
const scaleData = computeScaleData(m);
|
||||
if (scaleData) {
|
||||
open.attrSet('data-scalable', JSON.stringify(scaleData));
|
||||
}
|
||||
|
||||
newTokens.push(open);
|
||||
|
||||
// Display metric-normalized text by default
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue