feat: added scale buttons

This commit is contained in:
Leyla Becker 2026-02-22 14:03:14 -06:00
parent a96734c394
commit 53c25c9da3
4 changed files with 391 additions and 22 deletions

View file

@ -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;

View file

@ -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';
}

View file

@ -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,

View file

@ -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