Implementation
Widget renderCanvasLayer(Size availableSize) {
return RepaintBoundary(
key: boundaryRepaintKey,
child: Container(
color: widget.theme.background,
child: MouseRegion(
onExit: (event) {
if (mousePosition != null) {
setState(() => mousePosition = null);
}
},
child: Listener(
onPointerSignal: (pointerEvent) {
if (widget.isFullScreen && pointerEvent is PointerScrollEvent) {
applyZoomAtPoint(
pointerEvent.localPosition,
pointerEvent.scrollDelta.dy < 0 ? 1.15 : 0.87,
);
}
},
onPointerHover: (pointerEvent) {
detectTooltipIntersection(pointerEvent.localPosition);
if (isEraserModeActive) {
setState(() => mousePosition = pointerEvent.localPosition);
}
},
onPointerDown: (pointerEvent) {
if (pointerEvent.buttons == kSecondaryButton) {
setState(() {
isDrawingModeActive = !isDrawingModeActive;
if (isDrawingModeActive) isEraserModeActive = false;
});
}
},
child: GestureDetector(
onScaleStart: (gestureDetails) {
if ((isDrawingModeActive || isEraserModeActive) &&
gestureDetails.pointerCount == 1) {
final worldCoordinates = transformScreenToWorld(
gestureDetails.localFocalPoint.dx,
gestureDetails.localFocalPoint.dy,
);
setState(() => activeStroke = [worldCoordinates]);
} else {
previousFocalPoint = gestureDetails.localFocalPoint;
previousScaleGesture = 1.0;
}
},
onScaleUpdate: (gestureDetails) {
if ((isDrawingModeActive || isEraserModeActive) &&
gestureDetails.pointerCount == 1 &&
activeStroke != null) {
final worldCoordinates = transformScreenToWorld(
gestureDetails.localFocalPoint.dx,
gestureDetails.localFocalPoint.dy,
);
setState(() {
activeStroke = [...activeStroke!, worldCoordinates];
if (isEraserModeActive)
mousePosition = gestureDetails.localFocalPoint;
});
} else {
setState(() {
if (gestureDetails.pointerCount >= 2 &&
previousScaleGesture != null) {
final dynamicFactor =
gestureDetails.scale / previousScaleGesture!;
final proposedScale = currentScale * dynamicFactor;
final clampedScale =
proposedScale.clamp(0.00001, 5000000.0);
final actualFactor = clampedScale / currentScale;
currentOrigin = gestureDetails.localFocalPoint +
(currentOrigin - gestureDetails.localFocalPoint) *
actualFactor;
currentScale = clampedScale;
previousScaleGesture = gestureDetails.scale;
} else if (previousFocalPoint != null) {
currentOrigin += gestureDetails.localFocalPoint -
previousFocalPoint!;
}
previousFocalPoint = gestureDetails.localFocalPoint;
});
}
},
onScaleEnd: (gestureDetails) {
if ((isDrawingModeActive || isEraserModeActive) &&
activeStroke != null &&
activeStroke!.length > 1) {
final finalizedStroke = FreehandItem(
worldPoints: List.from(activeStroke!),
color: widget.freehandColor,
strokeWidth: isEraserModeActive
? currentEraserWidth
: widget.freehandWidth,
isEraser: isEraserModeActive,
baseScale: currentScale,
);
setState(() {
recordedStrokes.add(finalizedStroke);
undoneStrokes.clear();
});
widget.onFreehandStroke?.call(List.from(activeStroke!));
}
setState(() => activeStroke = null);
previousFocalPoint = null;
previousScaleGesture = null;
},
onTapUp: (gestureDetails) {
detectTooltipIntersection(gestureDetails.localPosition);
if (widget.onTap != null) {
final worldCoordinates = transformScreenToWorld(
gestureDetails.localPosition.dx,
gestureDetails.localPosition.dy,
);
widget.onTap!(worldCoordinates.dx, worldCoordinates.dy);
}
},
onLongPressStart: (gestureDetails) {
if (widget.onLongPress != null) {
final worldCoordinates = transformScreenToWorld(
gestureDetails.localPosition.dx,
gestureDetails.localPosition.dy,
);
widget.onLongPress!(
worldCoordinates.dx, worldCoordinates.dy);
}
},
child: Stack(
children: [
CustomPaint(
size: availableSize,
painter: CanvasRenderer(
origin: currentOrigin,
scale: currentScale,
items: widget.items,
freehandItems: List.from(recordedStrokes),
currentStroke: activeStroke,
currentIsEraser: isEraserModeActive,
showGrid: isGridVisible,
freehandColor: widget.freehandColor,
freehandWidth: widget.freehandWidth,
eraserWidth: currentEraserWidth,
mousePosition: mousePosition,
theme: widget.theme,
worldToScreen: transformWorldToScreen,
screenToWorld: transformScreenToWorld,
),
),
...widget.widgetItems.map((widgetItem) {
final mappedScreenPoint = transformWorldToScreen(
widgetItem.x,
widgetItem.y,
);
return Positioned(
left: mappedScreenPoint.dx -
50 * (widgetItem.alignment.x + 1) / 2,
top: mappedScreenPoint.dy -
50 * (widgetItem.alignment.y + 1) / 2,
child: widgetItem.child,
);
}),
if (widget.showLegend)
LegendWidget(items: widget.items, theme: widget.theme),
if (activeTooltip != null)
TooltipWidget(
tooltipData: activeTooltip!,
size: availableSize,
theme: widget.theme,
),
if (widget.showControls && widget.isFullScreen)
FloatingToolbar(
theme: widget.theme,
drawingMode: isDrawingModeActive,
eraserMode: isEraserModeActive,
gridVisible: isGridVisible,
isFullScreen: widget.isFullScreen,
onFitToContent: fitToContent,
onResetView: resetView,
onZoomIn: () => zoomFromCenter(1.3),
onZoomOut: () => zoomFromCenter(0.77),
onToggleGrid: () =>
setState(() => isGridVisible = !isGridVisible),
onToggleDrawing: () => setState(() {
isDrawingModeActive = !isDrawingModeActive;
if (isDrawingModeActive) isEraserModeActive = false;
}),
onToggleEraser: () => setState(() {
isEraserModeActive = !isEraserModeActive;
if (isEraserModeActive) isDrawingModeActive = false;
if (!isEraserModeActive) mousePosition = null;
}),
onClearFreehand: clearFreehand,
onUndoFreehand: undoFreehand,
onRedoFreehand: redoFreehand,
onToggleFullScreen: toggleFullScreen,
),
if (widget.showControls &&
activeTooltip != null &&
widget.isFullScreen)
FloatingCoordinates(
tooltipData: activeTooltip!,
theme: widget.theme,
),
if (widget.showControls && !widget.isFullScreen)
Positioned(
top: 12,
right: 12,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: BackdropFilter(
filter:
ui.ImageFilter.blur(sigmaX: 12.0, sigmaY: 12.0),
child: Container(
decoration: BoxDecoration(
color:
widget.theme.background.withOpacity(0.75),
border: Border.all(
color:
widget.theme.axisColor.withOpacity(0.1),
width: 1,
),
),
child: ControlIconButton(
iconData: Icons.fullscreen_rounded,
tooltipText: 'Pantalla Completa',
onTapCallback: toggleFullScreen,
activeTheme: widget.theme,
),
),
),
),
),
if (isEraserModeActive)
Positioned(
bottom: 24,
left: 0,
right: 0,
child: Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 12.0, sigmaY: 12.0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color:
widget.theme.background.withOpacity(0.75),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
widget.theme.axisColor.withOpacity(0.1),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cleaning_services_rounded,
size: 16,
color: widget.theme.axisColor
.withOpacity(0.7)),
const SizedBox(width: 8),
SizedBox(
width: 150,
child: SliderTheme(
data: const SliderThemeData(
trackHeight: 2.0,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: 6.0),
overlayShape: RoundSliderOverlayShape(
overlayRadius: 14.0),
),
child: Slider(
value: currentEraserWidth,
min: 5,
max: 50,
onChanged: (val) => setState(
() => currentEraserWidth = val),
activeColor: widget.theme.axisColor,
inactiveColor: widget.theme.axisColor
.withOpacity(0.2),
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 40,
child: Text(
'${currentEraserWidth.toInt()} px',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: widget.theme.axisColor
.withOpacity(0.8),
),
),
),
],
),
),
),
),
),
),
],
),
),
),
),
));
}