feat: implement interactive place value editing with NumberFlow animations

- Add SVG-based place value displays perfectly aligned with abacus columns
- Implement keyboard navigation: digits advance right, Tab/Backspace/Shift+Tab move focus
- Integrate @number-flow/react for smooth animated number transitions
- Support wraparound navigation at column boundaries
- Add visual feedback with blue highlighting for active editing state
- Enable seamless two-way binding between place values and bead positions

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-18 14:21:52 -05:00
parent ff42bcf653
commit 684e62463d
3 changed files with 240 additions and 16 deletions

View File

@@ -51,28 +51,28 @@
"animations"
],
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@react-spring/web": "^9.7.0",
"@use-gesture/react": "^10.3.0"
"@use-gesture/react": "^10.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"typescript": "^5.0.0",
"@storybook/addon-actions": "^7.6.0",
"@storybook/addon-controls": "^7.6.0",
"@storybook/addon-docs": "^7.6.0",
"@storybook/addon-essentials": "^7.6.0",
"@storybook/addon-interactions": "^7.6.0",
"@storybook/addon-links": "^7.6.0",
"@storybook/addon-controls": "^7.6.0",
"@storybook/addon-docs": "^7.6.0",
"@storybook/blocks": "^7.6.0",
"@storybook/react": "^7.6.0",
"@storybook/react-vite": "^7.6.0",
"@storybook/testing-library": "^0.2.2",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.6.0",
"typescript": "^5.0.0",
"vite": "^4.5.0"
},
"author": "Soroban Flashcards Team",
@@ -85,5 +85,8 @@
"engines": {
"node": ">=18.0.0",
"python": ">=3.8"
},
"dependencies": {
"@number-flow/react": "^0.5.10"
}
}

View File

@@ -624,3 +624,56 @@ This implementation uses pure CSS for smooth opacity transitions:
},
};
// Interactive Place Value Editing
export const InteractivePlaceValueEditing: Story = {
render: (args) => {
const [value, setValue] = useState(args.value || 123);
const handleBeadClick = (bead: any) => {
action('bead-clicked')(bead);
};
const handleValueChange = (newValue: number) => {
setValue(newValue);
action('value-changed')(newValue);
};
return (
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '20px' }}>
<h3>Interactive Place Value Editing</h3>
<p><strong>Instructions:</strong> Click on the number displays below each column to edit them directly!</p>
<p>Current Value: <strong>{value}</strong></p>
</div>
<AbacusReact
{...args}
value={value}
onClick={handleBeadClick}
onValueChange={handleValueChange}
/>
<div style={{ marginTop: '20px', fontSize: '14px', color: '#666' }}>
<p><strong>How to use:</strong> Click numbers below columns Type 0-9 Press Enter/Esc</p>
</div>
</div>
);
},
args: {
value: 123,
columns: 3,
beadShape: 'diamond',
colorScheme: 'place-value',
scaleFactor: 1.2,
animated: true,
gestures: true,
},
parameters: {
docs: {
description: {
story: 'SVG-based interactive place value editing with perfect alignment to abacus columns.',
},
},
},
};

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import { useSpring, animated, config, to } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import NumberFlow from '@number-flow/react';
// Types
export interface BeadConfig {
@@ -94,6 +95,12 @@ export function useAbacusState(initialValue: number = 0) {
const [columnStates, setColumnStates] = useState<ColumnState[]>(() => initializeFromValue(initialValue));
// Sync with prop changes
React.useEffect(() => {
console.log(`🔄 Syncing internal state to new prop value: ${initialValue}`);
setColumnStates(initializeFromValue(initialValue));
}, [initialValue, initializeFromValue]);
// Calculate current value from independent column states
const value = useMemo(() => {
return columnStates.reduce((total, columnState, index) => {
@@ -445,6 +452,11 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
}) => {
const { value: currentValue, columnStates, toggleBead, setColumnState } = useAbacusState(value);
// Debug prop changes
React.useEffect(() => {
console.log(`🔄 Component received value prop: ${value}, internal value: ${currentValue}`);
}, [value, currentValue]);
// Calculate effective columns
const effectiveColumns = useMemo(() => {
if (columns === 'auto') {
@@ -515,14 +527,110 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
}
}, [paddedColumnStates, setColumnState]);
// Place value editing - FRESH IMPLEMENTATION
const [activeColumn, setActiveColumn] = React.useState<number | null>(null);
// Calculate current place values
const placeValues = React.useMemo(() => {
return paddedColumnStates.map(state =>
(state.heavenActive ? 5 : 0) + state.earthActive
);
}, [paddedColumnStates]);
// Update a column from a digit
const setColumnValue = React.useCallback((columnIndex: number, digit: number) => {
if (digit < 0 || digit > 9) return;
setColumnState(columnIndex, {
heavenActive: digit >= 5,
earthActive: digit % 5
});
}, [setColumnState]);
// Keyboard handler
React.useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
console.log(`🎹 KEY: "${e.key}" | activeColumn: ${activeColumn} | code: ${e.code}`);
if (activeColumn === null) {
console.log(`❌ activeColumn is null, ignoring`);
return;
}
if (e.key >= '0' && e.key <= '9') {
console.log(`🔢 DIGIT: ${e.key} for column ${activeColumn}`);
e.preventDefault();
const digit = parseInt(e.key);
console.log(`📝 About to call setColumnValue(${activeColumn}, ${digit})`);
setColumnValue(activeColumn, digit);
// Move focus to the next column to the right
const nextColumn = activeColumn + 1;
if (nextColumn < effectiveColumns) {
console.log(`➡️ Moving focus to next column: ${nextColumn}`);
setActiveColumn(nextColumn);
} else {
console.log(`🏁 Reached last column, staying at: ${activeColumn}`);
}
} else if (e.key === 'Backspace' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault();
console.log(`⬅️ ${e.key === 'Backspace' ? 'BACKSPACE' : 'SHIFT+TAB'}: moving to previous column`);
// Move focus to the previous column to the left
const prevColumn = activeColumn - 1;
if (prevColumn >= 0) {
console.log(`⬅️ Moving focus to previous column: ${prevColumn}`);
setActiveColumn(prevColumn);
} else {
console.log(`🏁 Reached first column, wrapping to last column`);
setActiveColumn(effectiveColumns - 1); // Wrap around to last column
}
} else if (e.key === 'Tab') {
e.preventDefault();
console.log(`🔄 TAB: moving to next column`);
// Move focus to the next column to the right
const nextColumn = activeColumn + 1;
if (nextColumn < effectiveColumns) {
console.log(`➡️ Moving focus to next column: ${nextColumn}`);
setActiveColumn(nextColumn);
} else {
console.log(`🏁 Reached last column, wrapping to first column`);
setActiveColumn(0); // Wrap around to first column
}
} else if (e.key === 'Escape') {
e.preventDefault();
console.log(`🚪 ESCAPE: setting activeColumn to null`);
setActiveColumn(null);
}
};
console.log(`🔧 Setting up keyboard listener for activeColumn: ${activeColumn}`);
document.addEventListener('keydown', handleKey);
return () => {
console.log(`🗑️ Cleaning up keyboard listener for activeColumn: ${activeColumn}`);
document.removeEventListener('keydown', handleKey);
};
}, [activeColumn, setColumnValue, effectiveColumns]);
// Debug activeColumn changes
React.useEffect(() => {
console.log(`🎯 activeColumn changed to: ${activeColumn}`);
}, [activeColumn]);
return (
<svg
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className={`abacus-svg ${hideInactiveBeads ? 'hide-inactive-mode' : ''}`}
style={{ overflow: 'visible' }}
<div
className="abacus-container"
style={{ display: 'inline-block', textAlign: 'center', position: 'relative' }}
>
<svg
width={dimensions.width}
height={dimensions.height + 40}
viewBox={`0 0 ${dimensions.width} ${dimensions.height + 40}`}
className={`abacus-svg ${hideInactiveBeads ? 'hide-inactive-mode' : ''}`}
style={{ overflow: 'visible', display: 'block' }}
>
<defs>
<style>{`
/* CSS-based opacity system for hidden inactive beads */
@@ -638,7 +746,67 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
})
)}
{/* Background rectangles for place values - in SVG */}
{placeValues.map((value, columnIndex) => {
const x = (columnIndex * dimensions.rodSpacing) + dimensions.rodSpacing / 2;
const y = dimensions.height + 20;
const isActive = activeColumn === columnIndex;
return (
<rect
key={`place-bg-${columnIndex}`}
x={x - 12}
y={y - 12}
width={24}
height={24}
fill={isActive ? '#e3f2fd' : '#f5f5f5'}
stroke={isActive ? '#2196f3' : '#ccc'}
strokeWidth={isActive ? 2 : 1}
rx={3}
style={{ cursor: 'pointer' }}
onClick={() => setActiveColumn(columnIndex)}
/>
);
})}
</svg>
{/* NumberFlow place value displays - positioned over SVG */}
{placeValues.map((value, columnIndex) => {
const x = (columnIndex * dimensions.rodSpacing) + dimensions.rodSpacing / 2;
const y = dimensions.height + 20;
return (
<div
key={`place-number-${columnIndex}`}
style={{
position: 'absolute',
left: `${x - 12}px`,
top: `${y - 8}px`,
width: '24px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
fontSize: '14px',
fontFamily: 'monospace',
fontWeight: 'bold'
}}
>
<NumberFlow
value={value}
format={{ style: 'decimal' }}
style={{
fontFamily: 'monospace',
fontWeight: 'bold',
fontSize: '14px'
}}
/>
</div>
);
})}
</div>
);
};