feat: badge v2 auto-scaling text with Element_fit_text
Adds binary-search font auto-scaling for badge text fields, replacing
the character-count heuristic in v1. New files:
- action_fit_text.ts: Svelte action using binary search + MutationObserver
+ ResizeObserver. Pass null to disable (manual override mode).
- element_fit_text.svelte: Component wrapper with min/max/manual_size/
height/width props. height prop required for overflow detection to work.
- ae_comp__badge_obj_view_v2.svelte: Badge render using Element_fit_text
for name/title/affiliations/location in display mode. font_size_* props
default to undefined (auto-scale) instead of numeric defaults.
fit_heights derived object provides layout-aware section heights for
badge_3.5x5.5_pvc, badge_4x5_fanfold, and badge_4x6_fanfold layouts.
flex_justify() maps shorthand ('around','between','even') to CSS values.
Edit mode uses plain divs — inputs are never auto-scaled.
print/+page.svelte: Added v1/v2 toggle button in header. V1 preserved
as fallback. font_size_* passed as null (not ?? undefined) to v2 so
auto-scaling is active by default; manual override from print controls
still disables it per-field.
Docs: PROJECT__AE_Events_Badges_Review_Print.md updated with kiosk
workflow design intent, email address rule (always event_badge.email),
permission model alignment gap (TASK 4.0), and v2 implementation status.
TODO__Agents.md: completed items removed, badge polish tasks updated.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
87
src/lib/elements/action_fit_text.ts
Normal file
87
src/lib/elements/action_fit_text.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* action_fit_text.ts
|
||||
*
|
||||
* Svelte action that auto-scales text inside a fixed-size container using
|
||||
* binary search. Reacts to content changes (MutationObserver) and container
|
||||
* resize (ResizeObserver).
|
||||
*
|
||||
* Usage:
|
||||
* <div use:fit_text={{ min: 14, max: 48 }}>Some text</div>
|
||||
*
|
||||
* Pass null/undefined to disable (e.g. when a manual font size is active):
|
||||
* <div use:fit_text={manual_size != null ? null : { min: 14, max: 48 }}>
|
||||
*/
|
||||
|
||||
export interface FitTextParams {
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function fit_text(
|
||||
node: HTMLElement,
|
||||
params: FitTextParams | null | undefined
|
||||
) {
|
||||
if (!params) return {};
|
||||
|
||||
let { min = 16, max = 80 } = params;
|
||||
|
||||
// overflow:hidden is required so scrollWidth/scrollHeight accurately reflect overflow
|
||||
const prev_overflow = node.style.overflow;
|
||||
node.style.overflow = 'hidden';
|
||||
|
||||
function fits(): boolean {
|
||||
return node.scrollWidth <= node.offsetWidth && node.scrollHeight <= node.offsetHeight;
|
||||
}
|
||||
|
||||
function fit() {
|
||||
if (!params) return;
|
||||
|
||||
// Try max first — if it fits, no search needed
|
||||
node.style.fontSize = max + 'px';
|
||||
if (fits()) return;
|
||||
|
||||
// Binary search between min and max
|
||||
let lo = min;
|
||||
let hi = max;
|
||||
while (lo < hi - 1) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
node.style.fontSize = mid + 'px';
|
||||
if (fits()) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
node.style.fontSize = lo + 'px';
|
||||
}
|
||||
|
||||
// Re-fit when text content changes (handles {@html} updates)
|
||||
const mutation_observer = new MutationObserver(fit);
|
||||
mutation_observer.observe(node, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
// Re-fit when the container is resized (e.g. window resize, panel open/close)
|
||||
const resize_observer = new ResizeObserver(fit);
|
||||
resize_observer.observe(node);
|
||||
|
||||
// Defer initial fit to after the DOM has laid out
|
||||
requestAnimationFrame(fit);
|
||||
|
||||
return {
|
||||
update(new_params: FitTextParams | null | undefined) {
|
||||
if (!new_params) {
|
||||
node.style.overflow = prev_overflow;
|
||||
return;
|
||||
}
|
||||
params = new_params;
|
||||
min = new_params.min ?? 16;
|
||||
max = new_params.max ?? 80;
|
||||
node.style.overflow = 'hidden';
|
||||
requestAnimationFrame(fit);
|
||||
},
|
||||
destroy() {
|
||||
mutation_observer.disconnect();
|
||||
resize_observer.disconnect();
|
||||
node.style.overflow = prev_overflow;
|
||||
}
|
||||
};
|
||||
}
|
||||
98
src/lib/elements/element_fit_text.svelte
Normal file
98
src/lib/elements/element_fit_text.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* element_fit_text.svelte
|
||||
*
|
||||
* Wrapper component for the fit_text Svelte action. Renders a div that
|
||||
* auto-scales its font size to fill the available space (binary search).
|
||||
*
|
||||
* CRITICAL — height must be constrained for auto-scaling to work:
|
||||
* The action checks scrollHeight <= offsetHeight to detect overflow.
|
||||
* If the wrapper div has no explicit height it expands to fit content,
|
||||
* making scrollHeight == offsetHeight always — so the binary search
|
||||
* returns max font immediately (appears broken / no scaling).
|
||||
*
|
||||
* Set height via the `height` prop (CSS string, e.g. "1.5in", "3rem", "80px"),
|
||||
* OR apply a Tailwind height class via the `class` prop (e.g. "h-[1.5in]"),
|
||||
* OR let a parent flex container with a defined size control the height
|
||||
* and pass class="h-full" to fill it.
|
||||
*
|
||||
* Props:
|
||||
* min — minimum font size in px (default: 16)
|
||||
* max — maximum font size in px (default: 80)
|
||||
* manual_size — when set, disables auto-scaling and applies this font size directly
|
||||
* disabled — when true, neither auto-scaling nor manual_size is applied
|
||||
* height — explicit CSS height for the wrapper div (e.g. "1.5in", "3rem")
|
||||
* width — explicit CSS width for the wrapper div (rarely needed; usually inherits)
|
||||
* class — extra Tailwind/CSS classes for the wrapper div
|
||||
* style — extra inline styles for the wrapper div
|
||||
*
|
||||
* Example — auto-scale to fill an explicitly sized region:
|
||||
* <Element_fit_text min={20} max={80} height="1.5in" class="leading-none">
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*
|
||||
* Example — auto-scale filling a flex parent (parent must have a defined height):
|
||||
* <!-- parent: flex-1 min-h-0 overflow-hidden -->
|
||||
* <Element_fit_text min={20} max={80} class="h-full leading-none">
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*
|
||||
* Example — manual override (disables auto-scale, applies fixed size):
|
||||
* <Element_fit_text min={20} max={80} manual_size={font_size_name}>
|
||||
* {attendee_name}
|
||||
* </Element_fit_text>
|
||||
*/
|
||||
|
||||
import { fit_text } from './action_fit_text';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
min?: number;
|
||||
max?: number;
|
||||
/** When set, disables auto-scaling and applies this size directly via inline style. */
|
||||
manual_size?: number | null;
|
||||
/** When true, neither auto-scaling nor manual_size is applied. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Explicit CSS height for the wrapper div (e.g. "1.5in", "80px", "3rem").
|
||||
* REQUIRED for auto-scaling unless height is controlled via class or a flex parent.
|
||||
* Without a constrained height, offsetHeight == scrollHeight always → max font returned.
|
||||
*/
|
||||
height?: string;
|
||||
/** Explicit CSS width for the wrapper div. Usually not needed — inherits from parent. */
|
||||
width?: string;
|
||||
class?: string;
|
||||
style?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
min = 16,
|
||||
max = 80,
|
||||
manual_size = null,
|
||||
disabled = false,
|
||||
height,
|
||||
width,
|
||||
class: extra_class = '',
|
||||
style: extra_style = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
// Pass null to the action when auto-scaling should be suppressed
|
||||
let action_params = $derived(disabled || manual_size != null ? null : { min, max });
|
||||
|
||||
// Compose the final inline style.
|
||||
// Priority: manual_size → height/width → extra_style (caller's additional styles)
|
||||
let computed_style = $derived(() => {
|
||||
const parts: string[] = [];
|
||||
if (manual_size != null) parts.push(`font-size: ${manual_size}px`);
|
||||
if (height) parts.push(`height: ${height}`);
|
||||
if (width) parts.push(`width: ${width}`);
|
||||
if (extra_style) parts.push(extra_style);
|
||||
return parts.join('; ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={extra_class} style={computed_style()} use:fit_text={action_params}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
Reference in New Issue
Block a user