eCourt-note/components/pdf/InkLayer.tsx

602 lines
26 KiB
TypeScript
Raw Normal View History

"use client";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { RedactionData, ShapeData, ShapeKind } from "@/db/schema";
import {
clamp,
pathToPercent,
pointToPercent,
pointToPx,
rectToPercent,
rectToPx,
toPx,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
import { annotationBBox, hitTestPath, hitTestShape } from "@/utils/geometry";
import type { InkStamp } from "./TextboxStampLayer";
/** เส้นวาดที่เก็บใน DB — จุดทุกจุดเป็น % ของหน้า, strokeWidth เป็น % ของความกว้าง */
export type InkStroke = {
id: string;
/** เจ้าของ — eraser ใช้กรอง hit-test เฉพาะของตัวเอง (F6) */
userId: string;
color: string;
strokeWidthPct: number;
points: Point[];
/** F7/E1: ความหนาต่อจุด (% ของความกว้างหน้า) — มีเฉพาะเส้นที่วาดด้วยสไตลัส */
widthsPct?: number[];
/** ความทึบของเส้น (0–1) — parent คำนวณให้แล้วรวม fallback ตามชนิดปากกา (effectiveDrawOpacity) */
opacity?: number;
};
/** รูปทรงที่เก็บใน DB (F5/B1) — พิกัด % ทั้งหมด */
export type InkShape = { id: string; userId: string; data: ShapeData };
/** ปิดพรางที่เก็บใน DB (F8/B4) — rect % ของหน้า */
export type InkRedaction = { id: string; userId: string; data: RedactionData };
/** รูปทรงที่กำลังลาก (px) */
type LiveShape = { kind: ShapeKind; start: Point; end: Point };
/**
* F7/E1: แปลง e.pressure (0–1, mouse ได้ 0.5 ตลอด) เป็นตัวคูณความหนา
* 0.5 + pressure → เมาส์/แตะนิ้วได้ 1.0 เท่าเดิมพอดี, ปากกากดเบา–หนักได้ 0.5–1.5 เท่า
*/
const pressureFactor = (pressure: number): number => 0.5 + clamp(pressure, 0, 1);
/**
* วาด "ตรง ๆ ด้วยมือ" แล้วปล่อย → snap เป็นเส้นตรงอัตโนมัติ (ไม่ต้องกด Shift):
* จุดทุกจุดต้องห่างจากเส้นตรง จุดเริ่ม→จุดสุดท้าย ไม่เกิน max(5% ของความยาว, 3px)
* เส้นโค้ง/ขดจริง ๆ จะหลุดเกณฑ์ → เหลือเป็น freehand ตามที่วาด
*/
function isNearlyStraight(pts: Point[]): boolean {
const a = pts[0];
const b = pts[pts.length - 1];
const len = Math.hypot(b.x - a.x, b.y - a.y);
if (len < 24) return false; // เส้นสั้นมาก (ขีดสั้น ๆ) ปล่อยตามที่วาด
const maxDev = Math.max(len * 0.05, 3);
for (const p of pts) {
// ระยะตั้งฉากจาก p ไปยังเส้นตรง a→b
const dev = Math.abs((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) / len;
if (dev > maxDev) return false;
}
return true;
}
/**
* SVG freehand (PLAN.md §6.2) + รูปทรง (PLAN-FEAT.md §3 F5) — ห้ามใส่ viewBox
* (ความหนาเส้นจะบิดเบี้ยวเมื่อ scale แกน x/y ไม่เท่ากัน) render ระบบ px จริง
* แปลง % → px ด้วยขนาดที่ ResizeObserver วัด
*/
export default function InkLayer({
size,
strokes,
shapes,
stamps,
redactions,
active,
nextStroke,
shapeTool,
eraseMode,
redactMode,
ownUserId,
onStrokeEnd,
onShapeEnd,
onRedactionEnd,
onErase,
}: {
/** ขนาด px ตามคำนวณ (ใช้เป็นค่าเริ่มต้นก่อน ResizeObserver วัดได้) */
size: Size;
/** เส้นเดิมจาก DB (พิกัด %) */
strokes: InkStroke[];
/** รูปทรงเดิมจาก DB (พิกัด %) */
shapes: InkShape[];
/** ตรายางเดิมจาก DB (พิกัด %) — render ที่ TextboxStampLayer แต่ eraser hit-test ที่นี่ */
stamps: InkStamp[];
/** ปิดพรางเดิมจาก DB (พิกัด %) — วาดทับบังข้อความจริงบนจอ (F8) */
redactions: InkRedaction[];
/** tool วาด/รูปทรง/eraser/redact → รับ pointer events */
active: boolean;
/** ค่าเริ่มต้นของเส้นใหม่ที่กำลังวาด (tool draw) — opacity 0–1 ใช้ตอน render */
nextStroke: { color: string; strokeWidthPct: number; opacity?: number };
/** tool shape → ชนิดรูปทรงที่จะวาด (null = โหมดวาดเส้น) */
shapeTool: { kind: ShapeKind; color: string; strokeWidthPct: number } | null;
/** tool erase → ลากผ่านเส้น/รูปทรงของตัวเองเพื่อลบ (F6/B3) */
eraseMode: boolean;
/** tool redact → ลากคลุมพื้นที่เพื่อปิดพราง (F8/B4) */
redactMode: boolean;
/** userId ของผู้ใช้ปัจจุบัน — hit-test เฉพาะรายการของตัวเอง */
ownUserId: string;
/** pointerup แล้วจุด ≥ 2 → ส่งจุด (% ของหน้า) + widths (%, เฉพาะปากกา) ให้ parent บันทึก */
onStrokeEnd?: (pointsPct: Point[], widthsPct?: number[]) => void;
/** pointerup แล้วรูปทรงมีขนาดพอ → ส่ง ShapeData (% ของหน้า) ให้ parent บันทึก */
onShapeEnd?: (data: ShapeData) => void;
/** pointerup แล้วพื้นที่พอ → ส่ง RedactionData (% ของหน้า) ให้ parent บันทึก */
onRedactionEnd?: (data: RedactionData) => void;
/** hit-test โดนรายการของตัวเอง → parent เรียก deleteAnnotation ทันที */
onErase?: (id: string) => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [measured, setMeasured] = useState<Size>(size);
const [livePoints, setLivePoints] = useState<Point[]>([]);
const [liveShape, setLiveShape] = useState<LiveShape | null>(null);
// Shift ค้างระหว่างวาด → เส้นตรงจากจุดเริ่มถึงตำแหน่งปัจจุบัน (กด/ปล่อยกลางทางได้)
const [shiftDown, setShiftDown] = useState(false);
const drawingRef = useRef(false);
// source of truth ของจุดที่กำลังวาด — อ่านตอน pointerup โดยไม่ต้องแอบ side effect ใน updater
const pointsRef = useRef<Point[]>([]);
// F7/E1: ตัวคูณความหนาต่อจุด (จาก pressure) — เก็บเฉพาะเส้นที่วาดด้วยปากกา
const factorsRef = useRef<number[] | null>(null);
const [liveFactors, setLiveFactors] = useState<number[] | null>(null);
// F7/E1 palm rejection: เคยเห็นสไตลัสในหน้านี้ → เลิกสนใจนิ้ว (กันฝ่ามือแตะ)
const sawPenRef = useRef(false);
// เช่นเดียวกับรูปทรงที่กำลังลาก (อ่านตอน pointerup จาก ref กันค้างหลัง move สุดท้าย)
const shapeRef = useRef<LiveShape | null>(null);
// FIX-8: rAF ของ live render ระหว่างวาด — ค้าง = ยังไม่ต้องตั้งเพิ่ม
const liveRafRef = useRef<number | null>(null);
// โหมด eraser กำลังกดค้าง (F6)
const erasingRef = useRef(false);
// พื้นที่ปิดพรางที่กำลังลาก (F8) — เก็บ bbox ตรง ๆ
const redactRef = useRef<{ start: Point; end: Point } | null>(null);
const [liveRedaction, setLiveRedaction] = useState<Rect | null>(null);
// re-render เมื่อ resize/zoom (rules/coordinates.md)
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
setMeasured({ width: el.clientWidth, height: el.clientHeight });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// FIX-10: mirror prop size ทันทีเมื่อ zoom — ไม่งั้นมี 1 render
// หลัง zoom ที่เส้นทั้งหน้าวาดด้วย scale เก่าก่อน ResizeObserver ยิงมายืนยันค่าจริง
// เทียบค่าก่อน set — prop เป็น inline object ใหม่ทุก parent render แต่ค่าเท่าเดิม
// ต้องไม่นับเป็นการเปลี่ยน state (กัน render ซ้ำเปลือง ๆ)
useEffect(() => {
setMeasured((prev) =>
prev.width === size.width && prev.height === size.height ? prev : size,
);
}, [size]);
// FIX-8: เลิกวาดกลางคันแล้ว unmount — ยกเลิก rAF ที่ค้างอยู่ด้วย
useEffect(
() => () => {
if (liveRafRef.current !== null) cancelAnimationFrame(liveRafRef.current);
},
[],
);
// ติดตาม Shift ระดับ window — กด/ปล่อยกลางทางระหว่างลากก็ต้อง snap เป็นเส้นตรง/คลายกลับทันที
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "Shift") setShiftDown(true);
};
const up = (e: KeyboardEvent) => {
if (e.key === "Shift") setShiftDown(false);
};
const clear = () => setShiftDown(false); // window blur ระหว่างกดค้าง
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("blur", clear);
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
window.removeEventListener("blur", clear);
};
}, []);
// clamp ให้อยู่ในหน้าเสมอ — pointer capture ส่งพิกัดนอก page ได้ และ % ติดลบ
// จะโดน Server Action ปฏิเสธทั้งเส้น (rules/coordinates.md: clamp 0–100)
const toLocalPx = (e: { clientX: number; clientY: number }): Point => {
// FIX-9: เอา non-null ! ออก — handler ยิงได้แค่ตอน DOM mount แล้ว
// ref จึงไม่ควรเป็น null (คืน 0,0 เผื่อไว้เฉย ๆ ให้ type ชัด)
const el = rootRef.current;
if (!el) return { x: 0, y: 0 };
const r = el.getBoundingClientRect();
return {
x: clamp(e.clientX - r.left, 0, r.width),
y: clamp(e.clientY - r.top, 0, r.height),
};
};
/** normalize จุดเริ่ม/จุดปลายของการลากเป็น bbox (px) — ใช้กับ redaction (F8) */
const bboxFromDrag = (d: { start: Point; end: Point }): Rect => ({
x: Math.min(d.start.x, d.end.x),
y: Math.min(d.start.y, d.end.y),
width: Math.abs(d.end.x - d.start.x),
height: Math.abs(d.end.y - d.start.y),
});
function handlePointerDown(e: ReactPointerEvent<HTMLDivElement>) {
if (!active || e.button !== 0) return;
// F7/E1 palm rejection: เคยเห็นปากกาในหน้านี้ → ละเว้นนิ้ว (แตะ/ฝ่ามือ)
if (e.pointerType === "pen") {
sawPenRef.current = true;
} else if (e.pointerType === "touch" && sawPenRef.current) {
return;
}
// pointer capture → เส้นไม่ขาดเวลาเลื่อนออกนอก page (PLAN.md §6.2)
e.currentTarget.setPointerCapture(e.pointerId);
if (eraseMode) {
erasingRef.current = true;
eraseAt(e);
return;
}
if (redactMode) {
const p = toLocalPx(e);
redactRef.current = { start: p, end: p };
setLiveRedaction({ x: p.x, y: p.y, width: 0, height: 0 });
return;
}
drawingRef.current = true;
const p = toLocalPx(e);
if (shapeTool) {
shapeRef.current = { kind: shapeTool.kind, start: p, end: p };
setLiveShape(shapeRef.current);
} else {
// FIX-8: state ต้องไม่แชร์ reference กับ ref (ref ถูก mutate push ระหว่างวาด)
pointsRef.current = [p];
setLivePoints([p]);
// ความหนาต่อจุดเก็บเฉพาะปากกา (mouse/touch ไม่มี pressure จริง → สเปกเดิม)
if (e.pointerType === "pen") {
factorsRef.current = [pressureFactor(e.pressure)];
} else {
factorsRef.current = null;
}
setLiveFactors(factorsRef.current ? [...factorsRef.current] : null);
}
}
function handlePointerMove(e: ReactPointerEvent<HTMLDivElement>) {
if (erasingRef.current) {
eraseAt(e);
return;
}
if (redactRef.current) {
redactRef.current = { ...redactRef.current, end: toLocalPx(e) };
setLiveRedaction(bboxFromDrag(redactRef.current));
return;
}
if (!drawingRef.current) return;
const p = toLocalPx(e);
if (shapeTool) {
shapeRef.current = shapeRef.current
? { ...shapeRef.current, end: p }
: null;
setLiveShape(shapeRef.current);
} else {
// FIX-8: push จุดลง ref ทันที (ข้อมูลที่บันทึกครบทุกจุดเหมือนเดิม) แต่ยิง
// re-render ครั้งเดียวต่อ frame ผ่าน rAF — กัน copy array + re-render SVG
// ทั้งเส้นใหม่ทุก pointermove (= O(n²) เมื่อเส้นยาวหลายพันจุด)
pointsRef.current.push(p);
if (factorsRef.current) {
factorsRef.current.push(pressureFactor(e.pressure));
}
if (liveRafRef.current === null) {
liveRafRef.current = requestAnimationFrame(() => {
liveRafRef.current = null;
setLivePoints([...pointsRef.current]);
setLiveFactors(factorsRef.current ? [...factorsRef.current] : null);
});
}
}
}
/** hit-test ตำแหน่ง pointer กับเส้น/รูปทรง/ตรายาง **ของตัวเอง** — โดนเมื่อไรลบทันทีทีละรายการ (F6) */
function eraseAt(e: ReactPointerEvent<HTMLDivElement>) {
if (!onErase || ownUserId === "") return;
const lp = toLocalPx(e);
const p = pointToPercent(lp, measured);
const pad = 6; // เผื่อความคาดหวังของผู้ใช้นอกเส้นจริง (px)
// ตรายาง render อยู่ layer บน (TextboxStampLayer) → เช็คก่อน ให้ตัวบนสุดชนะ
// (bbox = สูตรเดียวกับ render และกรอบ selection — annotationBBox)
for (const s of stamps) {
if (s.userId !== ownUserId) continue;
const b = annotationBBox({ type: "STAMP", data: s.data }, measured);
if (
lp.x >= b.x - pad &&
lp.x <= b.x + b.width + pad &&
lp.y >= b.y - pad &&
lp.y <= b.y + b.height + pad
) {
onErase(s.id);
return;
}
}
for (const s of strokes) {
if (s.userId !== ownUserId) continue;
const threshold = toPx(s.strokeWidthPct, measured.width) / 2 + pad;
if (hitTestPath(s.points, p, threshold, measured)) {
onErase(s.id);
return;
}
}
for (const s of shapes) {
if (s.userId !== ownUserId) continue;
const threshold = toPx(s.data.strokeWidth, measured.width) / 2 + pad;
if (hitTestShape(s.data, p, threshold, measured)) {
onErase(s.id);
return;
}
}
}
function endDrag() {
if (erasingRef.current) {
erasingRef.current = false;
return;
}
if (redactRef.current) {
const bbox = bboxFromDrag(redactRef.current);
redactRef.current = null;
setLiveRedaction(null);
// เล็กเกิน (คลิกเฉย ๆ) ไม่สร้าง
if (bbox.width >= 2 && bbox.height >= 2) {
onRedactionEnd?.({ rect: rectToPercent(bbox, measured) });
}
return;
}
if (!drawingRef.current) return;
drawingRef.current = false;
if (shapeTool) {
const st = shapeRef.current;
shapeRef.current = null;
setLiveShape(null);
if (st) endShape(st);
} else {
// FIX-8: ยกเลิก rAF ที่ยังค้างอยู่ก่อนเคลียร์ live state — ไม่งั้น callback
// ยิงหลังจากนี้แล้วเส้นที่วาดจบโผล่กลับมาอีกครั้งบนหน้าจอ
if (liveRafRef.current !== null) {
cancelAnimationFrame(liveRafRef.current);
liveRafRef.current = null;
}
const points = pointsRef.current;
const factors = factorsRef.current;
pointsRef.current = [];
factorsRef.current = null;
setLivePoints([]);
setLiveFactors(null);
if (points.length >= 2) {
// ปล่อยแล้ว snap เป็นเส้นตรง: Shift ค้าง = บังคับตรงเสมอ หรือวาดตรง ๆ ด้วยมือ
// (isNearlyStraight) → เหลือจุดเริ่ม→จุดสุดท้าย 2 จุด (ความหนา uniform)
const snapped = straight || isNearlyStraight(points);
const finalPoints = snapped ? [points[0], points[points.length - 1]] : points;
const widthsPct =
factors && !snapped
? factors.map((f) => nextStroke.strokeWidthPct * f)
: undefined;
onStrokeEnd?.(pathToPercent(finalPoints, measured), widthsPct);
}
}
}
/** คำนวณ ShapeData (%) จากรูปทรงที่ลากจบ — เล็กเกินไป (คลิกเฉย ๆ) ไม่สร้าง */
function endShape(st: LiveShape) {
const tool = shapeTool;
if (!tool) return;
const { start, end } = st;
if (st.kind === "rect" || st.kind === "ellipse") {
const w = Math.abs(end.x - start.x);
const h = Math.abs(end.y - start.y);
if (w < 2 || h < 2) return;
const rect = rectToPercent(
{
x: Math.min(start.x, end.x),
y: Math.min(start.y, end.y),
width: w,
height: h,
},
measured,
);
onShapeEnd?.({
shape: st.kind,
rect,
strokeColor: tool.color,
strokeWidth: tool.strokeWidthPct,
});
} else {
if (Math.hypot(end.x - start.x, end.y - start.y) < 2) return;
onShapeEnd?.({
shape: st.kind,
from: pointToPercent(start, measured),
to: pointToPercent(end, measured),
strokeColor: tool.color,
strokeWidth: tool.strokeWidthPct,
});
}
}
const toPathD = (points: Point[]): string =>
points
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
.join(" ");
/** หัวลูกศร — path สามเหลี่ยมคำนวณจาก from→to (F5 สเปก render) */
const arrowHeadPath = (from: Point, to: Point, sw: number): string => {
const head = Math.max(sw * 3.5, 8);
const ang = Math.atan2(to.y - from.y, to.x - from.x);
const spread = Math.PI / 7;
const p1 = {
x: to.x - head * Math.cos(ang - spread),
y: to.y - head * Math.sin(ang - spread),
};
const p2 = {
x: to.x - head * Math.cos(ang + spread),
y: to.y - head * Math.sin(ang + spread),
};
return `M${to.x} ${to.y} L${p1.x} ${p1.y} L${p2.x} ${p2.y} Z`;
};
const strokeProps = (color: string, sw: number, opacity?: number) => ({
stroke: color,
strokeWidth: sw,
// opacity < 1 = เส้นโปร่งแสงให้เห็นข้อความใต้เส้น (ค่ารวม fallback ตาม pen มาจาก parent แล้ว)
strokeOpacity: opacity ?? 1,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
fill: "none",
});
/** F7/E1: มี widths ต่อจุด → render เป็น segment ทีละท่อน (ความหนาต่างกัน), ไม่มี → path เดียวเหมือนเดิม */
function renderStrokeEl(
key: string,
color: string,
baseSwPct: number,
ptsPx: Point[],
widthsPct?: number[],
opacity?: number,
) {
if (widthsPct && widthsPct.length === ptsPx.length && ptsPx.length >= 2) {
return (
<g key={key}>
{ptsPx.slice(0, -1).map((p, i) => {
const q = ptsPx[i + 1];
const w =
(toPx(widthsPct[i], measured.width) +
toPx(widthsPct[i + 1], measured.width)) /
2;
return (
<line key={i} x1={p.x} y1={p.y} x2={q.x} y2={q.y} {...strokeProps(color, w, opacity)} />
);
})}
</g>
);
}
return (
<path
key={key}
d={toPathD(ptsPx)}
{...strokeProps(color, toPx(baseSwPct, measured.width), opacity)}
/>
);
}
/** render รูปทรงหนึ่งชิ้นจาก ShapeData (%) — ใช้ได้ทั้งของเดิมและ live preview */
function renderShapeEl(d: ShapeData, key?: string) {
const sw = toPx(d.strokeWidth, measured.width);
if ("rect" in d) {
// rect/ellipse เก็บเป็น bbox ใน data.rect
const r = rectToPx(d.rect, measured);
if (d.shape === "ellipse") {
return (
<ellipse
key={key}
cx={r.x + r.width / 2}
cy={r.y + r.height / 2}
rx={r.width / 2}
ry={r.height / 2}
{...strokeProps(d.strokeColor, sw)}
/>
);
}
return <rect key={key} x={r.x} y={r.y} width={r.width} height={r.height} {...strokeProps(d.strokeColor, sw)} />;
}
// line/arrow — จุดเริ่ม/จุดปลาย (D3)
const a = pointToPx(d.from, measured);
const b = pointToPx(d.to, measured);
const line = <line x1={a.x} y1={a.y} x2={b.x} y2={b.y} {...strokeProps(d.strokeColor, sw)} />;
if (d.shape === "line") return <g key={key}>{line}</g>;
return (
<g key={key}>
{line}
<path d={arrowHeadPath(a, b, sw)} fill={d.strokeColor} />
</g>
);
}
// เส้นตรงเมื่อ Shift ค้าง — เฉพาะโหมดวาดอิสระ (ไม่ใช่รูปทรง/eraser/redact)
const straight = shiftDown && !shapeTool && !eraseMode && !redactMode;
return (
<div
ref={rootRef}
className="absolute inset-0 z-[3] touch-none"
style={{ pointerEvents: active ? "auto" : "none" }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
{/* SVG แบบไม่มี viewBox — วาดในระบบ px จริงเท่าขนาดที่วัดได้ */}
<svg width={measured.width} height={measured.height} className="block">
{strokes.map((s) =>
renderStrokeEl(
s.id,
s.color,
s.strokeWidthPct,
s.points.map((p) => pointToPx(p, measured)),
s.widthsPct,
s.opacity,
),
)}
{shapes.map((s) => renderShapeEl(s.data, s.id))}
{/* REDACTION — ดำทึบ บังข้อความจริงบนจอ (F8/B4) */}
{redactions.map((rd) => {
const r = rectToPx(rd.data.rect, measured);
return (
<rect key={rd.id} x={r.x} y={r.y} width={r.width} height={r.height} fill="#000" />
);
})}
{liveRedaction && (
<rect
x={liveRedaction.x}
y={liveRedaction.y}
width={liveRedaction.width}
height={liveRedaction.height}
fill="#000"
/>
)}
{livePoints.length >= 2 &&
renderStrokeEl(
"live-stroke",
nextStroke.color,
nextStroke.strokeWidthPct,
straight
? [livePoints[0], livePoints[livePoints.length - 1]]
: livePoints,
liveFactors && !straight
? liveFactors.map((f) => nextStroke.strokeWidthPct * f)
: undefined,
nextStroke.opacity,
)}
{liveShape && shapeTool &&
renderShapeEl(
liveShape.kind === "rect" || liveShape.kind === "ellipse"
? {
shape: liveShape.kind,
rect: rectToPercent(
{
x: Math.min(liveShape.start.x, liveShape.end.x),
y: Math.min(liveShape.start.y, liveShape.end.y),
width: Math.abs(liveShape.end.x - liveShape.start.x),
height: Math.abs(liveShape.end.y - liveShape.start.y),
},
measured,
),
strokeColor: shapeTool.color,
strokeWidth: shapeTool.strokeWidthPct,
}
: {
shape: liveShape.kind,
from: pointToPercent(liveShape.start, measured),
to: pointToPercent(liveShape.end, measured),
strokeColor: shapeTool.color,
strokeWidth: shapeTool.strokeWidthPct,
},
)}
</svg>
</div>
);
}