diff --git a/api/lib/src/models/property.dart b/api/lib/src/models/property.dart index b5f905fa3263..573e4dbea83f 100644 --- a/api/lib/src/models/property.dart +++ b/api/lib/src/models/property.dart @@ -18,6 +18,8 @@ enum HorizontalAlignment { left, center, right, justify } enum VerticalAlignment { top, center, bottom } +enum StrokeStyle { solid, dotted } + @freezed sealed class Property with _$Property { @Implements() @@ -34,6 +36,9 @@ sealed class Property with _$Property { @Default(5) double strokeWidth, required PathShape shape, @Default(SRGBColor.black) @ColorJsonConverter() SRGBColor color, + @Default(StrokeStyle.solid) StrokeStyle strokeStyle, + @Default(1.0) double dashMultiplier, + @Default(1.0) double gapMultiplier, }) = ShapeProperty; const factory Property.polygon({ diff --git a/api/lib/src/models/property.freezed.dart b/api/lib/src/models/property.freezed.dart index c701ebe0b670..f117d2b9646b 100644 --- a/api/lib/src/models/property.freezed.dart +++ b/api/lib/src/models/property.freezed.dart @@ -191,12 +191,15 @@ as double, @JsonSerializable() class ShapeProperty implements Property { - const ShapeProperty({this.strokeWidth = 5, required this.shape, @ColorJsonConverter() this.color = SRGBColor.black, final String? $type}): $type = $type ?? 'shape'; + const ShapeProperty({this.strokeWidth = 5, required this.shape, @ColorJsonConverter() this.color = SRGBColor.black, this.strokeStyle = StrokeStyle.solid, this.dashMultiplier = 1.0, this.gapMultiplier = 1.0, final String? $type}): $type = $type ?? 'shape'; factory ShapeProperty.fromJson(Map json) => _$ShapePropertyFromJson(json); @override@JsonKey() final double strokeWidth; final PathShape shape; @override@JsonKey()@ColorJsonConverter() final SRGBColor color; +@JsonKey() final StrokeStyle strokeStyle; +@JsonKey() final double dashMultiplier; +@JsonKey() final double gapMultiplier; @JsonKey(name: 'type') final String $type; @@ -215,16 +218,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is ShapeProperty&&(identical(other.strokeWidth, strokeWidth) || other.strokeWidth == strokeWidth)&&(identical(other.shape, shape) || other.shape == shape)&&(identical(other.color, color) || other.color == color)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is ShapeProperty&&(identical(other.strokeWidth, strokeWidth) || other.strokeWidth == strokeWidth)&&(identical(other.shape, shape) || other.shape == shape)&&(identical(other.color, color) || other.color == color)&&(identical(other.strokeStyle, strokeStyle) || other.strokeStyle == strokeStyle)&&(identical(other.dashMultiplier, dashMultiplier) || other.dashMultiplier == dashMultiplier)&&(identical(other.gapMultiplier, gapMultiplier) || other.gapMultiplier == gapMultiplier)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,strokeWidth,shape,color); +int get hashCode => Object.hash(runtimeType,strokeWidth,shape,color,strokeStyle,dashMultiplier,gapMultiplier); @override String toString() { - return 'Property.shape(strokeWidth: $strokeWidth, shape: $shape, color: $color)'; + return 'Property.shape(strokeWidth: $strokeWidth, shape: $shape, color: $color, strokeStyle: $strokeStyle, dashMultiplier: $dashMultiplier, gapMultiplier: $gapMultiplier)'; } @@ -235,7 +238,7 @@ abstract mixin class $ShapePropertyCopyWith<$Res> implements $PropertyCopyWith<$ factory $ShapePropertyCopyWith(ShapeProperty value, $Res Function(ShapeProperty) _then) = _$ShapePropertyCopyWithImpl; @override @useResult $Res call({ - double strokeWidth, PathShape shape,@ColorJsonConverter() SRGBColor color + double strokeWidth, PathShape shape,@ColorJsonConverter() SRGBColor color, StrokeStyle strokeStyle, double dashMultiplier, double gapMultiplier }); @@ -252,12 +255,15 @@ class _$ShapePropertyCopyWithImpl<$Res> /// Create a copy of Property /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? strokeWidth = null,Object? shape = null,Object? color = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? strokeWidth = null,Object? shape = null,Object? color = null,Object? strokeStyle = null,Object? dashMultiplier = null,Object? gapMultiplier = null,}) { return _then(ShapeProperty( strokeWidth: null == strokeWidth ? _self.strokeWidth : strokeWidth // ignore: cast_nullable_to_non_nullable as double,shape: null == shape ? _self.shape : shape // ignore: cast_nullable_to_non_nullable as PathShape,color: null == color ? _self.color : color // ignore: cast_nullable_to_non_nullable -as SRGBColor, +as SRGBColor,strokeStyle: null == strokeStyle ? _self.strokeStyle : strokeStyle // ignore: cast_nullable_to_non_nullable +as StrokeStyle,dashMultiplier: null == dashMultiplier ? _self.dashMultiplier : dashMultiplier // ignore: cast_nullable_to_non_nullable +as double,gapMultiplier: null == gapMultiplier ? _self.gapMultiplier : gapMultiplier // ignore: cast_nullable_to_non_nullable +as double, )); } diff --git a/api/lib/src/models/property.g.dart b/api/lib/src/models/property.g.dart index 72d5bc66206d..a735afe5e58e 100644 --- a/api/lib/src/models/property.g.dart +++ b/api/lib/src/models/property.g.dart @@ -37,6 +37,11 @@ ShapeProperty _$ShapePropertyFromJson(Map json) => ShapeProperty( color: json['color'] == null ? SRGBColor.black : const ColorJsonConverter().fromJson((json['color'] as num).toInt()), + strokeStyle: + $enumDecodeNullable(_$StrokeStyleEnumMap, json['strokeStyle']) ?? + StrokeStyle.solid, + dashMultiplier: (json['dashMultiplier'] as num?)?.toDouble() ?? 1.0, + gapMultiplier: (json['gapMultiplier'] as num?)?.toDouble() ?? 1.0, $type: json['type'] as String?, ); @@ -45,9 +50,17 @@ Map _$ShapePropertyToJson(ShapeProperty instance) => 'strokeWidth': instance.strokeWidth, 'shape': instance.shape.toJson(), 'color': const ColorJsonConverter().toJson(instance.color), + 'strokeStyle': _$StrokeStyleEnumMap[instance.strokeStyle]!, + 'dashMultiplier': instance.dashMultiplier, + 'gapMultiplier': instance.gapMultiplier, 'type': instance.$type, }; +const _$StrokeStyleEnumMap = { + StrokeStyle.solid: 'solid', + StrokeStyle.dotted: 'dotted', +}; + PolygonProperty _$PolygonPropertyFromJson(Map json) => PolygonProperty( strokeWidth: (json['strokeWidth'] as num?)?.toDouble() ?? 5, color: json['color'] == null diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 70926c2a3376..ad52ff823428 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -30,6 +30,9 @@ "zoomOut": "Zoom out", "resetZoom": "Reset zoom", "strokeWidth": "Stroke width", + "strokeStyle": "Stroke style", + "dashLength": "Dash length", + "gapLength": "Gap length", "includeEraser": "Include eraser?", "thinning": "Thinning", "pen": "Pen", diff --git a/app/lib/renderers/elements/shape.dart b/app/lib/renderers/elements/shape.dart index 56212bc9eac5..76ada598b4bc 100644 --- a/app/lib/renderers/elements/shape.dart +++ b/app/lib/renderers/elements/shape.dart @@ -9,6 +9,45 @@ class ShapeRenderer extends Renderer { ShapeRenderer(super.element, [super.layer]); + /// Creates a dotted path from the source path based on stroke style + Path _createDashedPath(Path source, StrokeStyle strokeStyle) { + if (strokeStyle == StrokeStyle.solid) return source; + + final property = element.property; + final strokeWidth = property.strokeWidth; + final baseDashLength = strokeWidth; // Dotted: 1x stroke width + final baseGapLength = strokeWidth * 2; + final dashLength = baseDashLength * property.dashMultiplier; + final gapLength = baseGapLength * property.gapMultiplier; + + final dashedPath = Path(); + for (final metric in source.computeMetrics()) { + double distance = 0; + bool draw = true; + while (distance < metric.length) { + final length = draw ? dashLength : gapLength; + final end = (distance + length).clamp(0.0, metric.length); + if (draw) { + dashedPath.addPath(metric.extractPath(distance, end), Offset.zero); + } + distance = end; + draw = !draw; + } + } + return dashedPath; + } + + /// Draws a path with the appropriate stroke style (solid, dashed, or dotted) + void _drawStyledPath(Canvas canvas, Path path, Paint paint) { + final strokeStyle = element.property.strokeStyle; + if (strokeStyle == StrokeStyle.solid) { + canvas.drawPath(path, paint); + } else { + final dashedPath = _createDashedPath(path, strokeStyle); + canvas.drawPath(dashedPath, paint); + } + } + @override void build( Canvas canvas, @@ -52,16 +91,15 @@ class ShapeRenderer extends Renderer { ), ); if (strokeWidth > 0) { - canvas.drawRRect( - RRect.fromRectAndCorners( - rect, - topLeft: topLeftCornerRadius, - topRight: topRightCornerRadius, - bottomLeft: bottomLeftCornerRadius, - bottomRight: bottomRightCornerRadius, - ), - paint, + final rrect = RRect.fromRectAndCorners( + drawRect, + topLeft: topLeftCornerRadius, + topRight: topRightCornerRadius, + bottomLeft: bottomLeftCornerRadius, + bottomRight: bottomRightCornerRadius, ); + final path = Path()..addRRect(rrect); + _drawStyledPath(canvas, path, paint); } } else if (shape is CircleShape) { canvas.drawOval( @@ -72,14 +110,14 @@ class ShapeRenderer extends Renderer { ), ); if (strokeWidth > 0) { - canvas.drawOval(rect, paint); + final path = Path()..addOval(drawRect); + _drawStyledPath(canvas, path, paint); } } else if (shape is LineShape) { - canvas.drawLine( - element.firstPosition.toOffset(), - element.secondPosition.toOffset(), - paint, - ); + final path = Path() + ..moveTo(element.firstPosition.x, element.firstPosition.y) + ..lineTo(element.secondPosition.x, element.secondPosition.y); + _drawStyledPath(canvas, path, paint); } else if (shape is TriangleShape) { final topCenter = drawRect.topCenter; final path = Path() @@ -95,7 +133,7 @@ class ShapeRenderer extends Renderer { ), ); if (strokeWidth > 0) { - canvas.drawPath(path, paint); + _drawStyledPath(canvas, path, paint); } } } @@ -107,6 +145,19 @@ class ShapeRenderer extends Renderer { ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; + /// Returns SVG stroke-dasharray attribute value based on stroke style + String? _getSvgDashArray() { + final property = element.property; + final strokeWidth = property.strokeWidth; + final dashMultiplier = property.dashMultiplier; + final gapMultiplier = property.gapMultiplier; + return switch (property.strokeStyle) { + StrokeStyle.solid => null, + StrokeStyle.dotted => + '${strokeWidth * dashMultiplier},${strokeWidth * 2 * gapMultiplier}', + }; + } + @override void buildSvg( XmlDocument xml, @@ -118,6 +169,8 @@ class ShapeRenderer extends Renderer { final shape = element.property.shape; final strokeWidth = element.property.strokeWidth; final drawRect = rect.inflate(-strokeWidth); + final dashArray = _getSvgDashArray(); + if (shape is RectangleShape) { final topLeftRadius = shape.topLeftCornerRadius / 100 * drawRect.shortestSide; @@ -155,6 +208,7 @@ class ShapeRenderer extends Renderer { 'fill': shape.fillColor.toHexString(), 'stroke': element.property.color.toHexString(), 'stroke-width': '${element.property.strokeWidth}px', + if (dashArray != null) 'stroke-dasharray': dashArray, }, ); } else if (shape is CircleShape) { @@ -170,6 +224,7 @@ class ShapeRenderer extends Renderer { 'fill': shape.fillColor.toHexString(), 'stroke': element.property.color.toHexString(), 'stroke-width': '${element.property.strokeWidth}px', + if (dashArray != null) 'stroke-dasharray': dashArray, }, ); } else if (shape is LineShape) { @@ -185,6 +240,25 @@ class ShapeRenderer extends Renderer { 'stroke-width': '${element.property.strokeWidth}px', 'stroke': element.property.color.toHexString(), 'fill': 'none', + if (dashArray != null) 'stroke-dasharray': dashArray, + }, + ); + } else if (shape is TriangleShape) { + final topCenter = drawRect.topCenter; + final d = + 'M${topCenter.dx} ${topCenter.dy} ' + 'L${drawRect.right} ${drawRect.bottom} ' + 'L${drawRect.left} ${drawRect.bottom} Z'; + xml + .getElement('svg') + ?.createElement( + 'path', + attributes: { + 'd': d, + 'fill': shape.fillColor.toHexString(), + 'stroke': element.property.color.toHexString(), + 'stroke-width': '${element.property.strokeWidth}px', + if (dashArray != null) 'stroke-dasharray': dashArray, }, ); } diff --git a/app/lib/selections/elements/shape.dart b/app/lib/selections/elements/shape.dart index 8bb0101ffd98..1905220984f7 100644 --- a/app/lib/selections/elements/shape.dart +++ b/app/lib/selections/elements/shape.dart @@ -44,6 +44,30 @@ class ShapeElementSelection extends ElementSelection { .toList(), ), ), + ExactSlider( + header: Text(AppLocalizations.of(context).strokeWidth), + value: element.property.strokeWidth, + min: 0, + max: 70, + defaultValue: 5, + onChangeEnd: (value) => updateElements( + context, + elements + .map( + (e) => e.copyWith( + property: e.property.copyWith(strokeWidth: value), + ), + ) + .toList(), + ), + ), + _ShapeElementStrokeStyleSection( + property: element.property, + onPropertyChanged: (property) => updateElements( + context, + elements.map((e) => e.copyWith(property: property)).toList(), + ), + ), ShapeView( shape: element.property.shape, onChanged: (value) => updateElements( @@ -73,3 +97,88 @@ class ShapeElementSelection extends ElementSelection { String getLocalizedName(BuildContext context) => AppLocalizations.of(context).shape; } + +class _ShapeElementStrokeStyleSection extends StatefulWidget { + final ShapeProperty property; + final ValueChanged onPropertyChanged; + + const _ShapeElementStrokeStyleSection({ + required this.property, + required this.onPropertyChanged, + }); + + @override + State<_ShapeElementStrokeStyleSection> createState() => + _ShapeElementStrokeStyleSectionState(); +} + +class _ShapeElementStrokeStyleSectionState + extends State<_ShapeElementStrokeStyleSection> { + bool _advancedExpanded = false; + + @override + Widget build(BuildContext context) { + final property = widget.property; + final isStyled = property.strokeStyle != StrokeStyle.solid; + + return ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() { + _advancedExpanded = isExpanded; + }); + }, + children: [ + ExpansionPanel( + canTapOnHeader: true, + isExpanded: _advancedExpanded, + headerBuilder: (context, isExpanded) => ListTile( + title: Text(AppLocalizations.of(context).strokeStyle), + trailing: DropdownMenu( + initialSelection: property.strokeStyle, + dropdownMenuEntries: StrokeStyle.values + .map( + (e) => DropdownMenuEntry( + label: e.getLocalizedName(context), + value: e, + leadingIcon: Icon(e.icon(PhosphorIconsStyle.light)), + ), + ) + .toList(), + onSelected: (value) => widget.onPropertyChanged( + property.copyWith(strokeStyle: value ?? StrokeStyle.solid), + ), + ), + ), + body: Column( + children: [ + ExactSlider( + header: Text(AppLocalizations.of(context).dashLength), + value: property.dashMultiplier, + min: 0.1, + max: 5, + defaultValue: 1, + onChangeEnd: isStyled + ? (value) => widget.onPropertyChanged( + property.copyWith(dashMultiplier: value), + ) + : null, + ), + ExactSlider( + header: Text(AppLocalizations.of(context).gapLength), + value: property.gapMultiplier, + min: 0.1, + max: 5, + defaultValue: 1, + onChangeEnd: isStyled + ? (value) => widget.onPropertyChanged( + property.copyWith(gapMultiplier: value), + ) + : null, + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/selections/tools/shape.dart b/app/lib/selections/tools/shape.dart index 1b0126979882..fb2d4ef0ada0 100644 --- a/app/lib/selections/tools/shape.dart +++ b/app/lib/selections/tools/shape.dart @@ -108,6 +108,10 @@ class ShapeToolSelection extends ToolSelection { .toList(), ), ), + _StrokeStyleSection( + property: property, + onPropertyChanged: updateProperty, + ), ColorField( value: property.color.withValues(a: 255), onChanged: (color) => update( @@ -444,3 +448,86 @@ class _RectangleShapeViewState extends State<_RectangleShapeView> { ); } } + +class _StrokeStyleSection extends StatefulWidget { + final ShapeProperty property; + final ValueChanged onPropertyChanged; + + const _StrokeStyleSection({ + required this.property, + required this.onPropertyChanged, + }); + + @override + State<_StrokeStyleSection> createState() => _StrokeStyleSectionState(); +} + +class _StrokeStyleSectionState extends State<_StrokeStyleSection> { + bool _advancedExpanded = false; + + @override + Widget build(BuildContext context) { + final property = widget.property; + final isStyled = property.strokeStyle != StrokeStyle.solid; + + return ExpansionPanelList( + expansionCallback: (index, isExpanded) { + setState(() { + _advancedExpanded = isExpanded; + }); + }, + children: [ + ExpansionPanel( + canTapOnHeader: true, + isExpanded: _advancedExpanded, + headerBuilder: (context, isExpanded) => ListTile( + title: Text(AppLocalizations.of(context).strokeStyle), + trailing: DropdownMenu( + initialSelection: property.strokeStyle, + dropdownMenuEntries: StrokeStyle.values + .map( + (e) => DropdownMenuEntry( + label: e.getLocalizedName(context), + value: e, + leadingIcon: Icon(e.icon(PhosphorIconsStyle.light)), + ), + ) + .toList(), + onSelected: (value) => widget.onPropertyChanged( + property.copyWith(strokeStyle: value ?? StrokeStyle.solid), + ), + ), + ), + body: Column( + children: [ + ExactSlider( + header: Text(AppLocalizations.of(context).dashLength), + value: property.dashMultiplier, + min: 0.1, + max: 5, + defaultValue: 1, + onChangeEnd: isStyled + ? (value) => widget.onPropertyChanged( + property.copyWith(dashMultiplier: value), + ) + : null, + ), + ExactSlider( + header: Text(AppLocalizations.of(context).gapLength), + value: property.gapMultiplier, + min: 0.1, + max: 5, + defaultValue: 1, + onChangeEnd: isStyled + ? (value) => widget.onPropertyChanged( + property.copyWith(gapMultiplier: value), + ) + : null, + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/visualizer/property.dart b/app/lib/visualizer/property.dart index c3d762189f64..f30ef0f5fc63 100644 --- a/app/lib/visualizer/property.dart +++ b/app/lib/visualizer/property.dart @@ -22,3 +22,18 @@ extension PathShapeVisualizer on PathShape { }; } } + +extension StrokeStyleVisualizer on StrokeStyle { + String getLocalizedName(BuildContext context) { + final loc = AppLocalizations.of(context); + return switch (this) { + StrokeStyle.solid => loc.solid, + StrokeStyle.dotted => loc.dotted, + }; + } + + IconGetter get icon => switch (this) { + StrokeStyle.solid => PhosphorIcons.minus, + StrokeStyle.dotted => PhosphorIcons.dotsSix, + }; +}