Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/lib/src/models/property.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathProperty>()
Expand All @@ -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({
Expand Down
20 changes: 13 additions & 7 deletions api/lib/src/models/property.freezed.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions api/lib/src/models/property.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions app/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 90 additions & 16 deletions app/lib/renderers/elements/shape.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,45 @@ class ShapeRenderer extends Renderer<ShapeElement> {

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,
Expand Down Expand Up @@ -52,16 +91,15 @@ class ShapeRenderer extends Renderer<ShapeElement> {
),
);
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(
Expand All @@ -72,14 +110,14 @@ class ShapeRenderer extends Renderer<ShapeElement> {
),
);
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()
Expand All @@ -95,7 +133,7 @@ class ShapeRenderer extends Renderer<ShapeElement> {
),
);
if (strokeWidth > 0) {
canvas.drawPath(path, paint);
_drawStyledPath(canvas, path, paint);
}
}
}
Expand All @@ -107,6 +145,19 @@ class ShapeRenderer extends Renderer<ShapeElement> {
..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,
Expand All @@ -118,6 +169,8 @@ class ShapeRenderer extends Renderer<ShapeElement> {
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;
Expand Down Expand Up @@ -155,6 +208,7 @@ class ShapeRenderer extends Renderer<ShapeElement> {
'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) {
Expand All @@ -170,6 +224,7 @@ class ShapeRenderer extends Renderer<ShapeElement> {
'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) {
Expand All @@ -185,6 +240,25 @@ class ShapeRenderer extends Renderer<ShapeElement> {
'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,
},
);
}
Expand Down
Loading