feat(canvas): render remapped stroke segments in previews and exports

Shape previews, boolean previews, and SVG export now run stored
stroke segments through strokeSegmentsForEditedPath so the drawn
boundary styles always match the current path geometry, falling back
to the stored segments when no edge indexes are available.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:59:50 +08:00
parent b5aa547c36
commit 56a33b3454
2 changed files with 10 additions and 7 deletions
@@ -1,7 +1,7 @@
"use client";
import type { CanvasNode } from "@/domain/design";
import { normalizedPathDataToSvg } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
import { normalizedPathDataToSvg, strokeSegmentsForEditedPath } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
import { booleanShapePathResult } from "@/ui/pages/CanvasWorkspace/canvas/booleanShapePath";
import { isBooleanShapeGroupNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, type ShapeStrokeSegment } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
import { normalizeStrokeStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle";
@@ -27,11 +27,12 @@ export function ShapeNodePreview({ node }: { node: CanvasNode }) {
if (options.kind === "path") {
const path = normalizedPathDataToSvg(options.pathData, width, height);
const hasSegmentStrokes = Boolean(options.strokeSegments?.length);
const strokeSegments = options.pathData ? strokeSegmentsForEditedPath(options.pathData, options.strokeSegments) || options.strokeSegments || [] : [];
const hasSegmentStrokes = strokeSegments.length > 0;
return (
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
<path d={path} fill={fill} fillOpacity={opacity} fillRule="evenodd" stroke={hasSegmentStrokes ? "none" : stroke} strokeWidth={strokeWidth} strokeDasharray={dashArray} strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
{hasSegmentStrokes && <ShapeStrokeSegments segments={options.strokeSegments || []} width={width} height={height} />}
{hasSegmentStrokes && <ShapeStrokeSegments segments={strokeSegments} width={width} height={height} />}
</svg>
);
}
@@ -90,7 +91,8 @@ function BooleanShapePreview({ node }: { node: CanvasNode }) {
const height = Math.max(node.height, 1);
const computed = options.pathData?.length && options.strokeSegments?.length ? null : booleanShapePathResult(options.operands, options.operation, options.width, options.height);
const pathData = options.pathData?.length ? options.pathData : computed?.pathData;
const strokeSegments = options.strokeSegments?.length ? options.strokeSegments : computed?.strokeSegments || [];
const storedStrokeSegments = options.strokeSegments?.length ? options.strokeSegments : computed?.strokeSegments || [];
const strokeSegments = pathData ? strokeSegmentsForEditedPath(pathData, storedStrokeSegments) || storedStrokeSegments : [];
const path = normalizedPathDataToSvg(pathData, width, height);
const fill = node.fillColor || "transparent";
const stroke = node.strokeColor || "#111827";
@@ -3,7 +3,7 @@ import type { Justification, Layer as PsdLayer, LayerTextData, Psd, RGB } from "
import { imageAdjustmentImageStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments";
import { booleanShapePathResult } from "@/ui/pages/CanvasWorkspace/canvas/booleanShapePath";
import { isBooleanShapeGroupNode, isShapeNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, type ShapeStrokeSegment } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode";
import { normalizedPathDataToSvg } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
import { normalizedPathDataToSvg, strokeSegmentsForEditedPath } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor, normalizeStrokeStyle } from "./frameStyle";
export type ImageExportFormat = "png" | "jpg" | "svg" | "psd";
@@ -503,7 +503,8 @@ function booleanShapeSvgFragment(node: CanvasNode, x: number, y: number, width:
const options = parseBooleanShapeOptions(node.content);
const computed = options.pathData?.length && options.strokeSegments?.length ? null : booleanShapePathResult(options.operands, options.operation, options.width, options.height);
const pathData = options.pathData?.length ? options.pathData : computed?.pathData;
const strokeSegments = options.strokeSegments?.length ? options.strokeSegments : computed?.strokeSegments || [];
const storedStrokeSegments = options.strokeSegments?.length ? options.strokeSegments : computed?.strokeSegments || [];
const strokeSegments = pathData ? strokeSegmentsForEditedPath(pathData, storedStrokeSegments) || storedStrokeSegments : [];
const path = normalizedPathDataToSvg(pathData, width, height, x, y);
if (!path) return "";
const strokeWidth = Math.max(0, node.strokeWidth ?? defaultFrameStrokeWidth);
@@ -526,7 +527,7 @@ function shapeSvgFragment(node: CanvasNode, x: number, y: number, width: number,
const inset = strokeWidth / 2;
if (options.kind === "path") {
const strokeSegments = options.strokeSegments || [];
const strokeSegments = options.pathData ? strokeSegmentsForEditedPath(options.pathData, options.strokeSegments) || options.strokeSegments || [] : [];
return `<path d="${normalizedPathDataToSvg(options.pathData, width, height, x, y)}" ${fill} ${strokeSegments.length ? `stroke="none"` : stroke} fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"${transform}/>${shapeStrokeSvgFragments(strokeSegments, x, y, width, height, transform)}`;
}