UNPKG

rough-native

Version:

Create graphics using HTML Canvas or SVG with a hand-drawn, sketchy, appearance. Features comprehensive React hooks, memory management, and React 18 concurrent rendering support.

432 lines (431 loc) 19.2 kB
import { RoughGenerator } from './generator'; import { CONFIG } from './config'; export class RoughReactNativeSVG { constructor(config) { this.gen = new RoughGenerator(config); this.instanceId = Math.random().toString(36).substring(2, 15); this.registerInstance(); } registerInstance() { RoughReactNativeSVG.instanceCount++; // Set up automatic cleanup via FinalizationRegistry (when available) if (typeof FinalizationRegistry !== 'undefined' && !RoughReactNativeSVG.finalizationRegistry) { RoughReactNativeSVG.finalizationRegistry = new FinalizationRegistry((instanceId) => { this.handleInstanceFinalization(instanceId); }); } // Register this instance for automatic cleanup if (RoughReactNativeSVG.finalizationRegistry) { RoughReactNativeSVG.finalizationRegistry.register(this, this.instanceId); } // Start cleanup timer only if this is the first instance if (RoughReactNativeSVG.instanceCount === 1) { this.startCleanupTimer(); } } handleInstanceFinalization(_instanceId) { RoughReactNativeSVG.instanceCount = Math.max(0, RoughReactNativeSVG.instanceCount - 1); // Stop cleanup timer if no instances remain if (RoughReactNativeSVG.instanceCount === 0 && RoughReactNativeSVG.cleanupTimer) { clearInterval(RoughReactNativeSVG.cleanupTimer); RoughReactNativeSVG.cleanupTimer = null; // Also clear the error cache when no instances remain RoughReactNativeSVG.errorCache.clear(); } } startCleanupTimer() { if (RoughReactNativeSVG.cleanupTimer) { clearInterval(RoughReactNativeSVG.cleanupTimer); } RoughReactNativeSVG.cleanupTimer = setInterval(() => { this.cleanupErrorCache(); }, RoughReactNativeSVG.cacheCleanupInterval); } dispose() { // Unregister from finalization registry to prevent double cleanup if (RoughReactNativeSVG.finalizationRegistry) { RoughReactNativeSVG.finalizationRegistry.unregister(this); } this.handleInstanceFinalization(this.instanceId); } // Static method to force cleanup all instances (for testing/debugging) static forceCleanup() { if (RoughReactNativeSVG.cleanupTimer) { clearInterval(RoughReactNativeSVG.cleanupTimer); RoughReactNativeSVG.cleanupTimer = null; } RoughReactNativeSVG.errorCache.clear(); RoughReactNativeSVG.instanceCount = 0; } cleanupErrorCache() { const now = Date.now(); const oneMinuteAgo = now - CONFIG.ERROR.MS_PER_MINUTE; const entries = Array.from(RoughReactNativeSVG.errorCache.entries()); for (const [key, value] of entries) { if (value.lastSeen < oneMinuteAgo) { RoughReactNativeSVG.errorCache.delete(key); } } // If cache is still too large, remove oldest entries if (RoughReactNativeSVG.errorCache.size > RoughReactNativeSVG.maxErrorCacheSize) { const sortedEntries = Array.from(RoughReactNativeSVG.errorCache.entries()) .sort((a, b) => a[1].lastSeen - b[1].lastSeen); const toRemove = sortedEntries.slice(0, sortedEntries.length - RoughReactNativeSVG.maxErrorCacheSize); toRemove.forEach(([key]) => RoughReactNativeSVG.errorCache.delete(key)); } } logError(context) { if (!this.shouldLog()) return; const errorKey = `${context.method}:${context.error || 'unknown'}`; const now = Date.now(); const existing = RoughReactNativeSVG.errorCache.get(errorKey); if (existing) { existing.count++; existing.lastSeen = now; // Rate limit: only log if under the limit const timeDiff = now - existing.context.timestamp; const errorRate = existing.count / Math.max(1, timeDiff / CONFIG.ERROR.MS_PER_MINUTE); // errors per minute if (errorRate > RoughReactNativeSVG.errorRateLimit) { return; // Skip logging due to rate limit } } else { RoughReactNativeSVG.errorCache.set(errorKey, { count: 1, lastSeen: now, context: Object.assign(Object.assign({}, context), { timestamp: now }), }); } // Enhanced logging with context const logData = { method: context.method, error: context.error, timestamp: new Date(now).toISOString(), }; if (context.parameters) { logData.parameters = this.sanitizeParameters(context.parameters); } if (existing && existing.count > 1) { logData.occurrences = existing.count; } console.warn(`rough-native: ${context.method} failed`, logData); } sanitizeParameters(params) { if (Array.isArray(params)) { return { type: 'array', length: params.length, sample: params.slice(0, CONFIG.ERROR.SAMPLE_SIZE_FOR_ARRAY_PARAMS), // Show first few items only }; } if (typeof params === 'object' && params !== null) { const sanitized = {}; for (const [key, value] of Object.entries(params)) { if (Array.isArray(value)) { sanitized[key] = { type: 'array', length: value.length }; } else if (typeof value === 'object' && value !== null) { sanitized[key] = { type: 'object', keys: Object.keys(value) }; } else { sanitized[key] = value; } } return sanitized; } return params; } shouldLog() { return typeof process === 'undefined' || process.env.NODE_ENV !== 'production'; } draw(drawable) { var _a; try { if (!drawable) { this.logError({ method: 'draw', error: 'drawable is null or undefined' }); return this.createEmptyElement(); } const sets = drawable.sets || []; const o = drawable.options || this.getDefaultOptions(); const children = []; const precision = (_a = drawable.options) === null || _a === void 0 ? void 0 : _a.fixedDecimalPlaceDigits; for (const drawing of sets) { if (!drawing) { this.logError({ method: 'draw', error: 'null drawing in drawable.sets' }); continue; } let element = null; try { switch (drawing.type) { case 'path': { const pathData = this.safeOpsToPath(drawing, precision); if (!pathData) { this.logError({ method: 'draw', error: 'failed to generate path data', parameters: { drawingType: 'path', drawing } }); continue; } element = { props: Object.assign(Object.assign({ d: pathData, stroke: o.stroke, strokeWidth: o.strokeWidth, fill: 'none' }, (o.strokeLineDash && { strokeDasharray: o.strokeLineDash.join(' ').trim() })), (o.strokeLineDashOffset && { strokeDashoffset: o.strokeLineDashOffset })), }; break; } case 'fillPath': { const pathData = this.safeOpsToPath(drawing, precision); if (!pathData) { this.logError({ method: 'draw', error: 'failed to generate fill path data', parameters: { drawingType: 'fillPath', drawing } }); continue; } element = { props: Object.assign({ d: pathData, stroke: 'none', strokeWidth: 0, fill: o.fill || '' }, (drawable.shape === 'curve' || drawable.shape === 'polygon' ? { fillRule: 'evenodd' } : {})), }; break; } case 'fillSketch': { element = this.safeFillSketch(drawing, o); break; } default: this.logError({ method: 'draw', error: `unknown drawing type: ${drawing.type}`, parameters: { drawing } }); continue; } } catch (drawingError) { this.logError({ method: 'draw', error: `failed to process ${drawing.type} drawing: ${drawingError}`, parameters: { drawingType: drawing.type, drawing } }); continue; } if (element && this.validateElement(element)) { children.push(element); } else if (element) { this.logError({ method: 'draw', error: 'generated element failed validation', parameters: { element, drawingType: drawing.type } }); } } return { props: {}, children, }; } catch (error) { this.logError({ method: 'draw', error: `draw method failed: ${error}`, parameters: { drawable } }); return this.createEmptyElement(); } } safeFillSketch(drawing, o) { try { const pathData = this.safeOpsToPath(drawing, o.fixedDecimalPlaceDigits); if (!pathData) { return null; } let fweight = o.fillWeight; if (fweight < 0) { fweight = o.strokeWidth / 2; } return { props: Object.assign(Object.assign({ d: pathData, stroke: o.fill || '', strokeWidth: fweight, fill: 'none' }, (o.fillLineDash && { strokeDasharray: o.fillLineDash.join(' ').trim() })), (o.fillLineDashOffset && { strokeDashoffset: o.fillLineDashOffset })), }; } catch (error) { this.logError({ method: 'safeFillSketch', error: `fillSketch generation failed: ${error}`, parameters: { drawing, options: o } }); return null; } } safeOpsToPath(drawing, fixedDecimalPlaceDigits) { try { if (!drawing || !drawing.ops || !Array.isArray(drawing.ops)) { return null; } return this.opsToPath(drawing, fixedDecimalPlaceDigits); } catch (error) { this.logError({ method: 'safeOpsToPath', error: `opsToPath conversion failed: ${error}`, parameters: { drawing, fixedDecimalPlaceDigits } }); return null; } } validateElement(element) { return element && typeof element === 'object' && element.props && typeof element.props === 'object'; } createEmptyElement() { return { props: {}, children: [], }; } validateCoordinates(coords) { return coords.every((coord) => typeof coord === 'number' && !isNaN(coord) && isFinite(coord)); } validateDimensions(...dimensions) { return dimensions.every((dim) => typeof dim === 'number' && !isNaN(dim) && isFinite(dim) && dim >= 0); } validatePoints(points) { return Array.isArray(points) && points.length > 0 && points.every((point) => Array.isArray(point) && point.length >= CONFIG.VALIDATION.MIN_POINT_ARRAY_LENGTH && this.validateCoordinates([point[0], point[1]])); } validateAngles(start, stop) { return this.validateCoordinates([start, stop]); } validateCurvePoints(points) { var _a; if (!Array.isArray(points) || points.length === 0) { return false; } // Handle Point[] case if (typeof ((_a = points[0]) === null || _a === void 0 ? void 0 : _a[0]) === 'number') { return this.validatePoints(points); } // Handle Point[][] case return points.every((pointArray) => this.validatePoints(pointArray)); } get generator() { return this.gen; } getDefaultOptions() { return this.gen.defaultOptions; } opsToPath(drawing, fixedDecimalPlaceDigits) { return this.gen.opsToPath(drawing, fixedDecimalPlaceDigits); } line(x1, y1, x2, y2, options) { try { if (!this.validateCoordinates([x1, y1, x2, y2])) { this.logError({ method: 'line', error: 'invalid coordinates', parameters: { x1, y1, x2, y2, options } }); return this.createEmptyElement(); } const d = this.gen.line(x1, y1, x2, y2, options); return this.draw(d); } catch (error) { this.logError({ method: 'line', error: `generation failed: ${error}`, parameters: { x1, y1, x2, y2, options } }); return this.createEmptyElement(); } } rectangle(x, y, width, height, options) { try { if (!this.validateCoordinates([x, y]) || !this.validateDimensions(width, height)) { this.logError({ method: 'rectangle', error: 'invalid parameters', parameters: { x, y, width, height, options } }); return this.createEmptyElement(); } const d = this.gen.rectangle(x, y, width, height, options); return this.draw(d); } catch (error) { this.logError({ method: 'rectangle', error: `generation failed: ${error}`, parameters: { x, y, width, height, options } }); return this.createEmptyElement(); } } ellipse(x, y, width, height, options) { try { if (!this.validateCoordinates([x, y]) || !this.validateDimensions(width, height)) { this.logError({ method: 'ellipse', error: 'invalid parameters', parameters: { x, y, width, height, options } }); return this.createEmptyElement(); } const d = this.gen.ellipse(x, y, width, height, options); return this.draw(d); } catch (error) { this.logError({ method: 'ellipse', error: `generation failed: ${error}`, parameters: { x, y, width, height, options } }); return this.createEmptyElement(); } } circle(x, y, diameter, options) { try { if (!this.validateCoordinates([x, y]) || !this.validateDimensions(diameter)) { this.logError({ method: 'circle', error: 'invalid parameters', parameters: { x, y, diameter, options } }); return this.createEmptyElement(); } const d = this.gen.circle(x, y, diameter, options); return this.draw(d); } catch (error) { this.logError({ method: 'circle', error: `generation failed: ${error}`, parameters: { x, y, diameter, options } }); return this.createEmptyElement(); } } linearPath(points, options) { try { if (!this.validatePoints(points)) { this.logError({ method: 'linearPath', error: 'invalid points', parameters: { points, options } }); return this.createEmptyElement(); } const d = this.gen.linearPath(points, options); return this.draw(d); } catch (error) { this.logError({ method: 'linearPath', error: `generation failed: ${error}`, parameters: { points, options } }); return this.createEmptyElement(); } } polygon(points, options) { try { if (!this.validatePoints(points) || points.length < CONFIG.VALIDATION.MIN_POLYGON_POINTS) { this.logError({ method: 'polygon', error: 'invalid points (need at least 3 points)', parameters: { points, options } }); return this.createEmptyElement(); } const d = this.gen.polygon(points, options); return this.draw(d); } catch (error) { this.logError({ method: 'polygon', error: `generation failed: ${error}`, parameters: { points, options } }); return this.createEmptyElement(); } } arc(x, y, width, height, start, stop, closed = false, options) { try { if (!this.validateCoordinates([x, y]) || !this.validateDimensions(width, height) || !this.validateAngles(start, stop)) { this.logError({ method: 'arc', error: 'invalid parameters', parameters: { x, y, width, height, start, stop, closed, options } }); return this.createEmptyElement(); } const d = this.gen.arc(x, y, width, height, start, stop, closed, options); return this.draw(d); } catch (error) { this.logError({ method: 'arc', error: `generation failed: ${error}`, parameters: { x, y, width, height, start, stop, closed, options } }); return this.createEmptyElement(); } } curve(points, options) { try { if (!this.validateCurvePoints(points)) { this.logError({ method: 'curve', error: 'invalid points', parameters: { points, options } }); return this.createEmptyElement(); } const d = this.gen.curve(points, options); return this.draw(d); } catch (error) { this.logError({ method: 'curve', error: `generation failed: ${error}`, parameters: { points, options } }); return this.createEmptyElement(); } } path(d, options) { try { if (!d || typeof d !== 'string' || d.trim().length === CONFIG.VALIDATION.MIN_PATH_STRING_LENGTH) { this.logError({ method: 'path', error: 'invalid path data', parameters: { d, options } }); return this.createEmptyElement(); } const drawing = this.gen.path(d, options); return this.draw(drawing); } catch (error) { this.logError({ method: 'path', error: `generation failed: ${error}`, parameters: { d, options } }); return this.createEmptyElement(); } } } RoughReactNativeSVG.errorCache = new Map(); RoughReactNativeSVG.maxErrorCacheSize = CONFIG.ERROR.MAX_ERROR_CACHE_SIZE; RoughReactNativeSVG.errorRateLimit = CONFIG.ERROR.ERROR_RATE_LIMIT_PER_MINUTE; RoughReactNativeSVG.cacheCleanupInterval = CONFIG.ERROR.ERROR_CACHE_CLEANUP_INTERVAL_MS; RoughReactNativeSVG.cleanupTimer = null; RoughReactNativeSVG.instanceCount = 0; RoughReactNativeSVG.finalizationRegistry = null;