eCourt-note/components/pdf/InkLayer.tsx

601 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}