Migration: Grow/Shrink Buttons to Next_Previous
Extracted from architecture/ux/controls.md
Current Implementation
The grow/shrink buttons are currently implemented as separate Button components:
- Grow button: Uses
T_Control.grow, custom SVG pathsvgPaths.t_cross(size_big, 2) - Shrink button: Uses
T_Control.shrink, custom SVG pathsvgPaths.dash(size_big, 4) - Positioning: Separate
lefts[2]andlefts[3]indices - Handler:
e.handle_s_mouseFor_t_control(s_mouse, T_Control.grow/shrink) - No autorepeat: Currently requires repeated clicks
Target Implementation
Replace with Next_Previous component to:
- Gain autorepeat: Built-in autorepeat functionality for continuous scaling
- Unify pattern: Consistent with recents control pattern
- Reduce code: Single component instead of two separate buttons
- Better UX: Autorepeat allows smooth continuous scaling
Challenges
- Custom SVG Icons:
Next_PrevioususesT_Direction.previous/nextarrows, but grow/shrink needt_crossanddashicons - Icon Mapping: Need to map grow → "next" (increase) and shrink → "previous" (decrease) conceptually
- Positioning:
Next_Previoususesorigin(single point), while current uses separatecenterpoints - Handler Interface:
Next_Previousclosure receivescolumn: number(0=previous, 1=next), needs adapter forT_Controlenum - Visual Styling: May need to adjust size/styling to match current appearance
Migration Steps
Option A: Extend Next_Previous Component (Recommended)
Add custom icon support to
Next_Previous.svelte:- Add optional prop:
custom_icons?: { previous: string, next: string }(SVG path data) - Modify SVG path generation to use custom icons when provided
- Fall back to
T_Directionpaths when not provided
- Add optional prop:
Update
Primary_Controls.svelte:- Remove the two separate
Buttoncomponents (lines 120-149) - Add single
Next_Previouscomponent:svelte<Next_Previous name='scale' size={size_big} has_title={false} origin={Point.x(lefts[2])} custom_icons={{ previous: svgPaths.dash(size_big, 4), next: svgPaths.t_cross(size_big, 2) }} closure={(column) => { const t_control = column === 0 ? T_Control.shrink : T_Control.grow; // Create synthetic S_Mouse event or call handler directly e.handle_s_mouseFor_t_control(/* ... */, t_control); }}/>
- Remove the two separate
Update
left_widths:- Combine indices 2 and 3 into single index for scale control
- Adjust width to accommodate both buttons in
Next_Previouslayout
Handler Adapter:
- Create adapter function that converts
Next_Previousclosure pattern toT_Controlpattern - Or modify
handle_s_mouseFor_t_controlto accept direct calls
- Create adapter function that converts
Option B: Create Scale_Controls Component
Create new component
Scale_Controls.sveltebased onNext_Previous.svelte:- Copy structure from
Next_Previous - Replace
T_Directionicons witht_crossanddash - Customize for grow/shrink specific needs
- Copy structure from
Use in
Primary_Controls.svelte:- Replace Button components with new
Scale_Controlscomponent - Maintain same positioning and handler interface
- Replace Button components with new
Considerations
- Autorepeat behavior: Ensure autorepeat works correctly for scaling operations
- Visual consistency: Maintain current button appearance (size, stroke, colors)
- Positioning:
Next_Previoususesorigin(left edge), may need adjustment for centering - Handler compatibility: See detailed analysis below
- Testing: Verify autorepeat doesn't cause performance issues with frequent scale updates
- Feature flag: Keep
features.has_zoom_controlscheck for conditional rendering
Handler Interface Analysis: Synthetic S_Mouse vs Refactoring
The Interface Mismatch
Current State:
Next_Previousclosure:(column: number) => any- Receives simple integer:
0= previous (shrink),1= next (grow) - Called during autorepeat with no mouse context
- Stores
autorepeat_events[index](MouseEvent) internally
- Receives simple integer:
handle_s_mouseFor_t_control:(s_mouse: S_Mouse, t_control: T_Control) => void- Requires full
S_Mouseobject withisDown,event,element - Only acts when
s_mouse.isDown === true - Currently receives
S_Mousefrom Button component's mouse handler
- Requires full
The Gap:Next_Previous provides column number and stored MouseEvent, but handle_s_mouseFor_t_control expects a complete S_Mouse object.
Approach A: Create Synthetic S_Mouse Events (Recommended)
Implementation
Create an adapter closure that constructs S_Mouse instances from Next_Previous's callback:
// In Primary_Controls.svelte
function handle_scale_control(column: number) {
// Get the stored autorepeat event from Next_Previous's internal state
// This requires accessing Next_Previous's autorepeat_events array
// OR: Modify Next_Previous to pass event to closure
// For initial click (isDown):
const s_mouse_down = S_Mouse.down(autorepeat_event, button_element);
const t_control = column === 0 ? T_Control.shrink : T_Control.grow;
e.handle_s_mouseFor_t_control(s_mouse_down, t_control);
// For autorepeat (isRepeat):
const s_mouse_repeat = S_Mouse.repeat(autorepeat_event, button_element);
e.handle_s_mouseFor_t_control(s_mouse_repeat, t_control);
}Challenge: Accessing Autorepeat Event
Next_Previous stores autorepeat_events[index] internally but doesn't expose it to the closure. Two solutions:
Solution A1: Modify Next_Previous to Pass Event
// In Next_Previous.svelte
s_element.autorepeat_callback = () => {
if (autorepeat_events[index]) {
closure(index, autorepeat_events[index], button_elements[index]); // Pass event and element
}
};
// Update closure signature
export let closure: (column: number, event: MouseEvent | null, element: HTMLElement | null) => any;Solution A2: Store Events in Parent Component
// In Primary_Controls.svelte
let scale_autorepeat_events: (MouseEvent | null)[] = [null, null];
function handle_scale_control(column: number, event: MouseEvent | null, element: HTMLElement | null) {
scale_autorepeat_events[column] = event;
const t_control = column === 0 ? T_Control.shrink : T_Control.grow;
if (event) {
// Determine if this is initial down or repeat
// Next_Previous calls closure on initial down AND during autorepeat
// Need to distinguish: first call = down, subsequent = repeat
const isFirstCall = !scale_autorepeat_events[column]; // Simplified check
const s_mouse = isFirstCall
? S_Mouse.down(event, element)
: S_Mouse.repeat(event, element);
e.handle_s_mouseFor_t_control(s_mouse, t_control);
}
}Pros of Approach A
- ✅ Minimal changes: Works with existing
handle_s_mouseFor_t_controlsignature - ✅ Type safety: Uses existing
S_Mousetype system - ✅ Consistency: Maintains same handler pattern as other controls
- ✅ Future-proof: If handler needs more
S_Mouseproperties later, they're available - ✅ Separation of concerns: Handler doesn't need to know about
Next_Previousinternals
Cons of Approach A
- ❌ Event tracking complexity: Need to track which call is first vs repeat
- ❌ Element reference: Need to get button element reference (may require Next_Previous modification)
- ❌ Synthetic nature: Creating events that didn't come from actual mouse interaction feels artificial
Implementation Details
Distinguishing Initial vs Repeat:
let scale_last_call_time: [number | null, number | null] = [null, null];
const REPEAT_THRESHOLD_MS = 100; // Time between initial and first repeat
function handle_scale_control(column: number, event: MouseEvent | null, element: HTMLElement | null) {
if (!event) return;
const now = Date.now();
const lastTime = scale_last_call_time[column];
const isRepeat = lastTime !== null && (now - lastTime) < REPEAT_THRESHOLD_MS;
scale_last_call_time[column] = now;
const s_mouse = isRepeat
? S_Mouse.repeat(event, element)
: S_Mouse.down(event, element);
const t_control = column === 0 ? T_Control.shrink : T_Control.grow;
e.handle_s_mouseFor_t_control(s_mouse, t_control);
}Better: Use Next_Previous's autorepeat_isFirstCall flag: If Next_Previous exposes whether it's the first call, we can use that directly.
Approach B: Refactor Handler Interface
Implementation
Create a new, simpler handler method that accepts direct control type:
// In Events.ts
handle_t_control_direct(t_control: T_Control, isRepeat: boolean = false) {
// Simplified handler that doesn't need S_Mouse
switch (t_control) {
case T_Control.grow:
g.scaleBy(k.ratio.zoom_in);
break;
case T_Control.shrink:
g.scaleBy(k.ratio.zoom_out);
break;
// ... other cases
}
}
// In Primary_Controls.svelte
<Next_Previous
closure={(column) => {
const t_control = column === 0 ? T_Control.shrink : T_Control.grow;
e.handle_t_control_direct(t_control);
}}
/>Pros of Approach B
- ✅ Simpler: No need to construct
S_Mouseobjects - ✅ Direct: Clear intent, no synthetic events
- ✅ Performance: Slightly faster (no object creation)
- ✅ Cleaner closure: Just maps column to control type
Cons of Approach B
- ❌ Interface proliferation: Creates second handler method alongside existing one
- ❌ Inconsistency: Different controls use different handler patterns
- ❌ Limited future flexibility: If handler needs mouse coordinates, modifiers, etc., need to add parameters
- ❌ Breaking pattern: Other controls use
handle_s_mouseFor_t_control, this would be exception - ❌ Loss of context: No access to
event(for coordinates, modifiers) orelement(for positioning)
Hybrid Approach B1: Overload with Optional S_Mouse
// In Events.ts
handle_s_mouseFor_t_control(s_mouse: S_Mouse | null, t_control: T_Control) {
if (s_mouse?.isDown || !s_mouse) { // Allow null for direct calls
switch (t_control) {
case T_Control.grow: g.scaleBy(k.ratio.zoom_in); break;
case T_Control.shrink: g.scaleBy(k.ratio.zoom_out); break;
// ...
}
}
}
// In Primary_Controls.svelte
<Next_Previous
closure={(column) => {
const t_control = column === 0 ? T_Control.shrink : T_Control.grow;
e.handle_s_mouseFor_t_control(null, t_control); // Pass null for S_Mouse
}}
/>This maintains interface consistency but loses mouse context.
Recommendation: Approach A with Next_Previous Modification
Best solution: Modify Next_Previous to pass event and element to closure, then use synthetic S_Mouse:
- Update Next_Previous.svelte:
export let closure: (column: number, event: MouseEvent | null, element: HTMLElement | null, isFirstCall: boolean) => any;
s_element.autorepeat_callback = () => {
if (autorepeat_events[index]) {
closure(index, autorepeat_events[index], button_elements[index], autorepeat_isFirstCall);
autorepeat_isFirstCall = false;
}
};- Use in Primary_Controls.svelte:
function handle_scale_control(column: number, event: MouseEvent | null, element: HTMLElement | null, isFirstCall: boolean) {
const t_control = column === 0 ? T_Control.shrink : T_Control.grow;
const s_mouse = isFirstCall
? S_Mouse.down(event, element)
: S_Mouse.repeat(event, element);
e.handle_s_mouseFor_t_control(s_mouse, t_control);
}
<Next_Previous
name='scale'
closure={handle_scale_control}
// ... other props
/>Why this is best:
- ✅ Maintains type safety and consistency
- ✅ Provides full mouse context when needed
- ✅ Clear distinction between initial click and repeat
- ✅ Minimal changes to existing handler interface
- ✅ Reusable pattern for other controls that might migrate to Next_Previous
Benefits After Migration
- Autorepeat: Users can hold button for continuous scaling
- Code reduction: ~30 lines of duplicate button code eliminated
- Consistency: Matches pattern used by recents control
- Maintainability: Single component to maintain instead of two