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
JavaScript
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;