24 KiB
Implementation Guide
Practical step-by-step guides for implementing features and fixing bugs. For architecture details, see ARCHITECTURE.md. For quick lookups, see QUICK_REFERENCE.md.
Table of Contents
- Adding New Features
- Fixing Bugs
- Common Customizations
- Performance Optimization
- Debugging Techniques
- Testing Strategies
Adding New Features
Feature: Add Rotation Support
Let's add the ability to rotate images with a two-finger twist gesture.
Step 1: Add Properties
File: interfaces.ts
export interface Properties {
// ... existing properties
enableRotation?: boolean;
rotationLockThreshold?: number; // Degrees before rotation activates
}
File: properties.ts
export const defaultProperties: Properties = {
// ... existing defaults
enableRotation: false,
rotationLockThreshold: 10,
};
Step 2: Add Input Signal
File: pinch-zoom.component.ts
export class PinchZoomComponent implements OnInit, OnDestroy {
// ... existing inputs
enableRotation = input<boolean>(defaultProperties.enableRotation!);
rotationLockThreshold = input<number>(defaultProperties.rotationLockThreshold!);
// Add to mergedProperties
mergedProperties = computed<ComponentProperties>(() => {
return {
// ... existing properties
enableRotation: this.enableRotation(),
rotationLockThreshold: this.rotationLockThreshold(),
};
});
}
Step 3: Add State to IvyPinch
File: ivypinch.ts
export class IvyPinch {
// ... existing properties
// Rotation state
public rotation: number = 0; // Current rotation in degrees
private initialRotation: number = 0; // Rotation at gesture start
private rotationAngle: number = 0; // Cumulative rotation
// ... existing methods
private handleRotation = (event: any): void => {
if (!this.properties.enableRotation) {
return;
}
// Get two touch points
const touches = event.touches;
if (touches.length !== 2) {
return;
}
const touch1 = touches[0];
const touch2 = touches[1];
// Calculate angle between fingers
const angle = Math.atan2(touch2.clientY - touch1.clientY, touch2.clientX - touch1.clientX) * (180 / Math.PI);
// Initialize on first move
if (this.rotationAngle === 0) {
this.rotationAngle = angle;
this.initialRotation = this.rotation;
return;
}
// Calculate rotation delta
let delta = angle - this.rotationAngle;
// Normalize to -180 to 180
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
// Apply threshold to prevent accidental rotation
if (Math.abs(delta) < this.properties.rotationLockThreshold!) {
return;
}
// Update rotation
this.rotation = this.initialRotation + delta;
// Apply transform with rotation
this.transformElementWithRotation(this.scale, this.moveX, this.moveY, this.rotation);
};
private transformElementWithRotation(scale: number, moveX: number, moveY: number, rotation: number): void {
const transform = `
translate3d(${moveX}px, ${moveY}px, 0)
scale(${scale})
rotate(${rotation}deg)
`;
this.element.style.transform = transform;
}
private resetRotation(): void {
this.rotationAngle = 0;
}
}
Step 4: Register Event Handler
File: ivypinch.ts (in constructor)
constructor(properties: Properties, private zoomChanged: (scale: number) => void) {
// ... existing initialization
// Register rotation handler alongside pinch
this.touches.on('pinch', (event) => {
this.handlePinch(event);
this.handleRotation(event); // Also handle rotation
});
// Reset rotation on touch end
this.touches.on('touchend', () => {
this.resetRotation();
});
}
Step 5: Add Output Event (Optional)
File: pinch-zoom.component.ts
export class PinchZoomComponent implements OnInit, OnDestroy {
// ... existing outputs
rotationChanged = output<number>();
// Update callback to include rotation
private handleScaleCallback = (scale: number, rotation?: number): void => {
this.currentScale.set(scale);
this.zoomChanged.emit(scale);
if (rotation !== undefined) {
this.rotationChanged.emit(rotation);
}
};
}
Step 6: Update IvyPinch Callback
File: ivypinch.ts
private zoomChanged: (scale: number, rotation?: number) => void;
private handleRotation = (event: any): void => {
// ... rotation logic
// Notify component of both scale and rotation
this.zoomChanged(this.scale, this.rotation);
};
Step 7: Test
<pinch-zoom [enableRotation]="true" [rotationLockThreshold]="15" (rotationChanged)="onRotationChange($event)">
<img src="image.jpg" />
</pinch-zoom>
Feature: Add Zoom Animation Easing
Add custom easing functions for smoother zoom animations.
Step 1: Define Easing Functions
File: ivypinch.ts
type EasingFunction = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
const easingFunctions = {
linear: (t: number) => t,
'ease-in': (t: number) => t * t,
'ease-out': (t: number) => t * (2 - t),
'ease-in-out': (t: number) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
};
Step 2: Add Property
File: interfaces.ts
export interface Properties {
// ... existing
zoomEasing?: 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out';
}
Step 3: Implement Animated Zoom
File: ivypinch.ts
private animateZoom(
targetScale: number,
duration: number = this.properties.transitionDuration!
): void {
const startScale = this.scale;
const startTime = Date.now();
const easing = easingFunctions[this.properties.zoomEasing || 'ease-out'];
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easing(progress);
// Interpolate scale
const currentScale = startScale + (targetScale - startScale) * easedProgress;
// Apply transform
this.transformElement(currentScale, this.moveX, this.moveY);
this.scale = currentScale;
this.zoomChanged(this.scale);
// Continue animation
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
Fixing Bugs
Bug: Image Jumps on First Touch
Problem: When user first touches the image, it jumps to a different position.
Cause: Initial touch position not properly stored, causing incorrect delta calculation.
Step 1: Identify the Issue
Add logging to touches.ts:
handleTouchStart(event: TouchEvent) {
console.log('[Touches] touchstart', {
touches: event.touches.length,
positions: Array.from(event.touches).map(t => ({
x: t.clientX,
y: t.clientY
}))
});
this.touches = Array.from(event.touches);
}
Step 2: Fix Initialization
File: ivypinch.ts
private handleLinearSwipe = (event: any): void => {
// Store initial position on first move
if (this.moveStartX === undefined) {
this.moveStartX = event.touches[0].clientX;
this.moveStartY = event.touches[0].clientY;
this.initialMoveX = this.moveX;
this.initialMoveY = this.moveY;
return; // Don't move on first touch
}
// Calculate delta from initial position
const deltaX = event.touches[0].clientX - this.moveStartX;
const deltaY = event.touches[0].clientY - this.moveStartY;
// Apply delta to initial position
this.moveX = this.initialMoveX + deltaX;
this.moveY = this.initialMoveY + deltaY;
this.transformElement(this.scale, this.moveX, this.moveY);
};
// Reset on touch end
private resetSwipeState(): void {
this.moveStartX = undefined;
this.moveStartY = undefined;
}
Step 3: Test Fix
- Touch image
- Verify no jump occurs
- Move finger
- Verify smooth panning
Bug: Double-Tap Sometimes Doesn't Work
Problem: Double-tap zoom is inconsistent.
Cause: Timing window too strict or touch positions too far apart.
Fix 1: Adjust Timing Window
File: touches.ts
detectDoubleTap(): boolean {
const now = Date.now();
const timeSinceLastTap = now - this.lastTapTime;
// Increase window from 300ms to 500ms
const DOUBLE_TAP_WINDOW = 500;
if (timeSinceLastTap < DOUBLE_TAP_WINDOW && timeSinceLastTap > 0) {
this.eventType = 'tap';
this.lastTapTime = 0;
return true;
}
this.lastTapTime = now;
clearTimeout(this.doubleTapTimeout);
this.doubleTapTimeout = window.setTimeout(() => {
this.lastTapTime = 0;
}, DOUBLE_TAP_WINDOW);
return false;
}
Fix 2: Check Touch Position Distance
detectDoubleTap(): boolean {
const now = Date.now();
const timeSinceLastTap = now - this.lastTapTime;
// Check if taps are close together in space
const touch = this.touches[0];
const touchDistance = this.lastTapPosition ?
Math.sqrt(
Math.pow(touch.clientX - this.lastTapPosition.x, 2) +
Math.pow(touch.clientY - this.lastTapPosition.y, 2)
) : 0;
const DOUBLE_TAP_WINDOW = 500;
const DOUBLE_TAP_DISTANCE = 50; // Max 50px apart
if (
timeSinceLastTap < DOUBLE_TAP_WINDOW &&
timeSinceLastTap > 0 &&
touchDistance < DOUBLE_TAP_DISTANCE
) {
this.eventType = 'tap';
this.lastTapTime = 0;
this.lastTapPosition = null;
return true;
}
// Store tap position
this.lastTapTime = now;
this.lastTapPosition = {
x: touch.clientX,
y: touch.clientY
};
clearTimeout(this.doubleTapTimeout);
this.doubleTapTimeout = window.setTimeout(() => {
this.lastTapTime = 0;
this.lastTapPosition = null;
}, DOUBLE_TAP_WINDOW);
return false;
}
Bug: Memory Leak on Component Destroy
Problem: Application memory grows over time when components are created and destroyed.
Cause: Event listeners not properly removed.
Step 1: Verify Listeners Are Removed
File: touches.ts
destroy(): void {
console.log('[Touches] Removing listeners');
// Remove all listeners
Object.keys(this.listeners).forEach(key => {
const listener = this.listeners[key];
const element = this.getElementToListenTo();
element.removeEventListener(key, listener);
});
// Clear references
this.listeners = {};
this.handlers = {
pinch: [],
pan: [],
tap: []
};
console.log('[Touches] Cleanup complete');
}
Step 2: Ensure destroy() is Called
File: pinch-zoom.component.ts
ngOnDestroy(): void {
console.log('[PinchZoom] Component destroying');
if (this.pinchZoom) {
this.pinchZoom.destroy();
this.pinchZoom = null as any; // Clear reference
}
console.log('[PinchZoom] Component destroyed');
}
Step 3: Test with Chrome DevTools
- Open Chrome DevTools
- Go to Memory tab
- Take heap snapshot
- Create/destroy component 10 times
- Take another heap snapshot
- Compare snapshots
- Verify no PinchZoomComponent instances retained
Common Customizations
Custom: Programmatic Zoom to Specific Point
Zoom to a specific coordinate programmatically.
File: ivypinch.ts
public zoomToPoint(
targetScale: number,
pointX: number,
pointY: number,
animated: boolean = true
): void {
// Constrain scale
targetScale = Math.max(this.minScale, targetScale);
targetScale = Math.min(this.maxScale, targetScale);
// Calculate position adjustment to keep point fixed
const scaleRatio = targetScale / this.scale;
const elementRect = this.element.getBoundingClientRect();
const centerX = elementRect.left + elementRect.width / 2;
const centerY = elementRect.top + elementRect.height / 2;
// Adjust position
const newMoveX = this.moveX - (pointX - centerX) * (scaleRatio - 1);
const newMoveY = this.moveY - (pointY - centerY) * (scaleRatio - 1);
if (animated) {
this.animateZoomToPoint(targetScale, newMoveX, newMoveY);
} else {
this.scale = targetScale;
this.moveX = newMoveX;
this.moveY = newMoveY;
this.transformElement(this.scale, this.moveX, this.moveY);
this.zoomChanged(this.scale);
}
}
private animateZoomToPoint(
targetScale: number,
targetMoveX: number,
targetMoveY: number
): void {
const startScale = this.scale;
const startMoveX = this.moveX;
const startMoveY = this.moveY;
const startTime = Date.now();
const duration = this.properties.transitionDuration!;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out function
const eased = progress * (2 - progress);
// Interpolate
const currentScale = startScale + (targetScale - startScale) * eased;
const currentMoveX = startMoveX + (targetMoveX - startMoveX) * eased;
const currentMoveY = startMoveY + (targetMoveY - startMoveY) * eased;
// Apply
this.scale = currentScale;
this.moveX = currentMoveX;
this.moveY = currentMoveY;
this.transformElement(this.scale, this.moveX, this.moveY);
this.zoomChanged(this.scale);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
Usage:
// In component
@ViewChild('pinchZoom') pinchZoomRef!: PinchZoomComponent;
zoomToTopLeft() {
const ivyPinch = this.pinchZoomRef['pinchZoom']; // Access private property
ivyPinch.zoomToPoint(2, 100, 100, true);
}
Custom: Zoom Level Indicator
Add a visual indicator of current zoom level.
File: pinch-zoom.component.ts
export class PinchZoomComponent implements OnInit, OnDestroy {
// ... existing code
// Add computed signal for zoom percentage
zoomPercentage = computed<number>(() => {
return Math.round(this.scale() * 100);
});
// Add computed signal for zoom bar width
zoomBarWidth = computed<string>(() => {
const max = this.maxScale();
const current = this.scale();
const percent = (current / max) * 100;
return `${percent}%`;
});
}
File: pinch-zoom.component.html
<div class="pinch-zoom-container">
<ng-content></ng-content>
<!-- Zoom indicator -->
@if (isZoomedIn()) {
<div class="zoom-indicator">
<div class="zoom-label">{{ zoomPercentage() }}%</div>
<div class="zoom-bar">
<div class="zoom-bar-fill" [style.width]="zoomBarWidth()"></div>
</div>
</div>
}
</div>
File: pinch-zoom.component.sass
.zoom-indicator
position: absolute
top: 10px
left: 10px
background: rgba(0, 0, 0, 0.7)
color: white
padding: 8px 12px
border-radius: 4px
font-size: 12px
pointer-events: none
z-index: 10
.zoom-label
margin-bottom: 4px
.zoom-bar
width: 100px
height: 4px
background: rgba(255, 255, 255, 0.3)
border-radius: 2px
overflow: hidden
.zoom-bar-fill
height: 100%
background: white
transition: width 0.1s
Performance Optimization
Optimization: Throttle Pan Events
Reduce the number of transform updates during panning.
File: ivypinch.ts
private lastPanTime: number = 0;
private readonly PAN_THROTTLE_MS = 16; // ~60fps
private handleLinearSwipe = (event: any): void => {
// Throttle pan events
const now = Date.now();
if (now - this.lastPanTime < this.PAN_THROTTLE_MS) {
return; // Skip this event
}
this.lastPanTime = now;
// ... existing pan logic
};
Optimization: Use RequestAnimationFrame
Batch transform updates to animation frames.
File: ivypinch.ts
private rafId: number | null = null;
private pendingTransform: {
scale: number;
moveX: number;
moveY: number;
} | null = null;
private transformElement(scale: number, moveX: number, moveY: number): void {
// Store pending transform
this.pendingTransform = { scale, moveX, moveY };
// Schedule update
if (this.rafId === null) {
this.rafId = requestAnimationFrame(() => {
if (this.pendingTransform) {
const { scale, moveX, moveY } = this.pendingTransform;
const transform = `
translate3d(${moveX}px, ${moveY}px, 0)
scale(${scale})
`;
this.element.style.transform = transform;
this.pendingTransform = null;
this.rafId = null;
}
});
}
}
public destroy(): void {
// Cancel pending animation frame
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
// ... existing destroy logic
}
Optimization: Debounce Resize Calculations
Avoid expensive calculations on every resize event.
File: ivypinch.ts
private resizeTimeout: number | null = null;
private handleResize = (): void => {
// Clear existing timeout
if (this.resizeTimeout !== null) {
clearTimeout(this.resizeTimeout);
}
// Debounce resize handling
this.resizeTimeout = window.setTimeout(() => {
this.recalculateMaxScale();
this.constrainScale();
this.resizeTimeout = null;
}, 200);
};
public destroy(): void {
// Clear resize timeout
if (this.resizeTimeout !== null) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
}
// ... existing destroy logic
}
Debugging Techniques
Technique 1: Visual Transform Debugging
Add visual overlay showing current transform state.
Create: transform-debugger.component.ts
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'transform-debugger',
standalone: true,
template: `
<div class="debug-overlay">
<div class="debug-info">
<div>Scale: {{ scale() }}</div>
<div>Position: ({{ moveX() }}, {{ moveY() }})</div>
<div>Zoomed: {{ isZoomedIn() ? 'Yes' : 'No' }}</div>
</div>
<div class="debug-grid"></div>
</div>
`,
styles: [
`
.debug-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 9999;
}
.debug-info {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.8);
color: #0f0;
padding: 10px;
font-family: monospace;
font-size: 12px;
}
.debug-grid {
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(0, 255, 0, 0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 0, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
}
`,
],
})
export class TransformDebugger {
scale = input<number>(1);
moveX = input<number>(0);
moveY = input<number>(0);
isZoomedIn = computed(() => this.scale() > 1);
}
Usage:
<pinch-zoom #pz>
<img src="image.jpg" />
</pinch-zoom>
<transform-debugger [scale]="pz.scale()" [moveX]="pz.moveX()" [moveY]="pz.moveY()"> </transform-debugger>
Technique 2: Event Flow Logging
Add comprehensive logging to trace event flow.
File: touches.ts
private logEvent(stage: string, data: any): void {
if (!this.debug) return;
console.log(`[Touches:${stage}]`, {
eventType: this.eventType,
touches: this.touches.length,
distance: this.distance,
...data
});
}
handleTouchStart(event: TouchEvent): void {
this.logEvent('touchstart', { count: event.touches.length });
// ... existing logic
}
handleTouchMove(event: TouchEvent): void {
this.logEvent('touchmove', {
gesture: this.eventType,
distance: this.distance
});
// ... existing logic
}
Testing Strategies
Unit Testing IvyPinch
File: ivypinch.spec.ts
import { IvyPinch } from './ivypinch';
import { Properties } from './interfaces';
describe('IvyPinch', () => {
let element: HTMLElement;
let ivyPinch: IvyPinch;
let zoomCallback: jasmine.Spy;
beforeEach(() => {
element = document.createElement('div');
element.innerHTML = '<img src="test.jpg" />';
document.body.appendChild(element);
zoomCallback = jasmine.createSpy('zoomCallback');
const properties: Properties = {
transitionDuration: 200,
doubleTap: true,
limitZoom: 3,
};
ivyPinch = new IvyPinch(properties, zoomCallback);
});
afterEach(() => {
ivyPinch.destroy();
document.body.removeChild(element);
});
it('should initialize with scale 1', () => {
expect(ivyPinch.scale).toBe(1);
});
it('should respect max zoom limit', () => {
// Simulate pinch zoom beyond limit
const mockEvent = {
touches: [
{ clientX: 0, clientY: 0 },
{ clientX: 500, clientY: 0 },
],
};
// This would zoom to 5x, but limit is 3x
ivyPinch['handlePinch'](mockEvent);
expect(ivyPinch.scale).toBeLessThanOrEqual(3);
});
it('should call zoom callback when scale changes', () => {
ivyPinch.scale = 2;
ivyPinch['zoomChanged'](2);
expect(zoomCallback).toHaveBeenCalledWith(2);
});
});
Integration Testing
File: pinch-zoom.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PinchZoomComponent } from './pinch-zoom.component';
describe('PinchZoomComponent', () => {
let component: PinchZoomComponent;
let fixture: ComponentFixture<PinchZoomComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PinchZoomComponent],
}).compileComponents();
fixture = TestBed.createComponent(PinchZoomComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should initialize with scale 1', () => {
expect(component.scale()).toBe(1);
});
it('should update isZoomedIn when scale changes', () => {
expect(component.isZoomedIn()).toBe(false);
component['currentScale'].set(2);
expect(component.isZoomedIn()).toBe(true);
});
it('should emit zoomChanged when scale updates', (done) => {
component.zoomChanged.subscribe((scale: number) => {
expect(scale).toBe(2);
done();
});
component['handleScaleCallback'](2);
});
});
Summary
This guide covers:
- ✅ Adding new features (rotation, easing)
- ✅ Fixing common bugs (jumps, double-tap, memory leaks)
- ✅ Common customizations (zoom to point, indicators)
- ✅ Performance optimization (throttling, RAF, debouncing)
- ✅ Debugging techniques (visual debugging, logging)
- ✅ Testing strategies (unit tests, integration tests)
For architecture details, see ARCHITECTURE.md. For quick lookups, see QUICK_REFERENCE.md.