canvu-react 0.3.10 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/native.cjs CHANGED
@@ -703,592 +703,142 @@ function skiaItemPlacementTransform(x, y, cx, cy, rotationRad) {
703
703
  result.push({ translateX: x }, { translateY: y });
704
704
  return result;
705
705
  }
706
- var HANDLE_ORDER = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
707
- function pointsToSmoothPathD(points) {
708
- if (points.length < 2) return null;
709
- const d = smoothFreehandPointsToPathD(points);
710
- return d || null;
706
+ function rgbaFromHexAndOpacity(hex, opacity) {
707
+ if (!hex) return hex;
708
+ if (opacity == null || opacity >= 1) return hex;
709
+ const r = parseInt(hex.slice(1, 3), 16);
710
+ const g = parseInt(hex.slice(3, 5), 16);
711
+ const b = parseInt(hex.slice(5, 7), 16);
712
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return hex;
713
+ return `rgba(${r},${g},${b},${opacity})`;
711
714
  }
712
- function renderEraserSkeleton(item, overlayStrokePx) {
713
- const b = normalizeRect(item.bounds);
714
- const sw = Math.max(item.strokeWidth ?? 2, overlayStrokePx);
715
- const common = {
716
- color: "#cbd5e1",
717
- style: "stroke",
718
- strokeWidth: sw,
719
- strokeCap: "round",
720
- strokeJoin: "round",
721
- antiAlias: true
722
- };
723
- const k = item.toolKind;
724
- if (k === "rect") {
725
- return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Rect, { x: 0, y: 0, width: b.width, height: b.height, ...common });
726
- }
727
- if (k === "ellipse") {
728
- const rx = Math.max(0, b.width / 2);
729
- const ry = Math.max(0, b.height / 2);
730
- if (Math.abs(rx - ry) < 1e-9) {
731
- return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Circle, { cx: rx, cy: ry, r: rx, ...common });
732
- }
733
- return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Circle, { cx: rx, cy: ry, r: Math.min(rx, ry), ...common });
734
- }
735
- if ((k === "line" || k === "arrow") && item.line) {
736
- const ln = item.line;
737
- const geometry = k === "arrow" ? computeStraightArrowGeometry(
738
- ln,
739
- Math.max(item.strokeWidth ?? 2, overlayStrokePx)
740
- ) : null;
741
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
742
- /* @__PURE__ */ jsxRuntime.jsx(
743
- reactNativeSkia.Line,
744
- {
745
- p1: reactNativeSkia.vec(ln.x1, ln.y1),
746
- p2: reactNativeSkia.vec(geometry?.shaftEndX ?? ln.x2, geometry?.shaftEndY ?? ln.y2),
747
- ...common
748
- }
749
- ),
750
- k === "arrow" && geometry ? /* @__PURE__ */ jsxRuntime.jsx(
751
- reactNativeSkia.Path,
752
- {
753
- path: `M ${geometry.headLeftX} ${geometry.headLeftY} L ${geometry.headTipX} ${geometry.headTipY} L ${geometry.headRightX} ${geometry.headRightY}`,
754
- ...common
755
- }
756
- ) : null
757
- ] });
758
- }
759
- if ((k === "draw" || k === "marker" || k === "pencil" || k === "brush") && item.pathPointsLocal) {
760
- const pts = item.pathPointsLocal;
761
- if (pts.length === 1 && pts[0]) {
762
- const p = pts[0];
763
- const dotR = Math.max((item.strokeWidth ?? 2) / 2, 2);
764
- return /* @__PURE__ */ jsxRuntime.jsx(
765
- reactNativeSkia.Circle,
766
- {
767
- cx: p.x,
768
- cy: p.y,
769
- r: dotR,
770
- color: "#cbd5e1",
771
- style: "fill",
772
- antiAlias: true
773
- }
774
- );
775
- }
776
- const d = pointsToSmoothPathD(pts);
777
- if (d) {
778
- return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Path, { path: d, ...common });
779
- }
780
- }
781
- return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Path, { path: `M0 0 h${b.width} v${b.height} h${-b.width} Z`, ...common });
715
+ function toNum(v) {
716
+ return typeof v === "number" ? v : Number(v) || 0;
782
717
  }
783
- function NativeInteractionOverlay({
784
- camera,
785
- width,
786
- height,
787
- selectedItems,
788
- showResizeHandles,
789
- placementPreview,
790
- eraserTrail,
791
- laserTrail,
792
- eraserPreviewItems = []
793
- }) {
794
- const z = camera.zoom;
795
- const camTransform = skiaCameraTransform(z, camera.x, camera.y);
796
- const handleR = 5 / z;
797
- const overlayStrokePx = 1.25;
798
- const rotateOffsetWorld = 24 / z;
799
- const selectionElements = react.useMemo(() => {
800
- if (selectedItems.length === 0) return null;
801
- const single = selectedItems.length === 1 ? selectedItems[0] : void 0;
802
- const bSingle = single ? normalizeRect(single.bounds) : null;
803
- const rotSingle = single?.rotation ?? 0;
804
- const rotHandlePos = showResizeHandles && bSingle && single ? getRotationHandleWorldPosition(bSingle, rotSingle, rotateOffsetWorld) : null;
805
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
806
- selectedItems.map((it) => {
807
- const b = normalizeRect(it.bounds);
808
- const cx = b.width / 2;
809
- const cy = b.height / 2;
810
- const t = skiaItemPlacementTransform(it.x, it.y, cx, cy, it.rotation ?? 0);
811
- return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Group, { transform: t, children: /* @__PURE__ */ jsxRuntime.jsx(
812
- reactNativeSkia.Rect,
718
+ function SvgNodeRenderer({ nodes }) {
719
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: nodes.map((node, i) => /* @__PURE__ */ jsxRuntime.jsx(SvgNodeItem, { node }, i)) });
720
+ }
721
+ function SvgNodeItem({ node }) {
722
+ const isFill = node.fill !== "none";
723
+ const hasStroke = node.stroke != null && node.stroke !== "none";
724
+ switch (node.kind) {
725
+ case "rect": {
726
+ const stroke = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
727
+ const fill = isFill ? rgbaFromHexAndOpacity(node.fill, node.fillOpacity) ?? node.fill : void 0;
728
+ const r = node.rx != null ? toNum(node.rx) : 0;
729
+ const w = toNum(node.width);
730
+ const h = toNum(node.height);
731
+ if (r > 0) {
732
+ return /* @__PURE__ */ jsxRuntime.jsx(
733
+ reactNativeSkia.RoundedRect,
813
734
  {
814
735
  x: 0,
815
736
  y: 0,
816
- width: b.width,
817
- height: b.height,
818
- color: "#3b82f6",
819
- style: "stroke",
820
- strokeWidth: overlayStrokePx,
737
+ width: w,
738
+ height: h,
739
+ r,
740
+ color: fill ?? stroke,
741
+ style: fill ? "fill" : hasStroke ? "stroke" : "fill",
742
+ strokeWidth: hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
821
743
  antiAlias: true
822
744
  }
823
- ) }, it.id);
824
- }),
825
- showResizeHandles && bSingle && single && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
826
- HANDLE_ORDER.map((hid) => {
827
- const p = getHandleWorldPositionRotated(bSingle, hid, rotSingle);
828
- return /* @__PURE__ */ jsxRuntime.jsx(
829
- reactNativeSkia.Circle,
830
- {
831
- cx: p.x,
832
- cy: p.y,
833
- r: handleR,
834
- color: "#ffffff",
835
- style: "fill",
836
- antiAlias: true
837
- },
838
- hid
839
- );
840
- }),
841
- rotHandlePos && /* @__PURE__ */ jsxRuntime.jsx(
745
+ );
746
+ }
747
+ return /* @__PURE__ */ jsxRuntime.jsx(
748
+ reactNativeSkia.Rect,
749
+ {
750
+ x: 0,
751
+ y: 0,
752
+ width: w,
753
+ height: h,
754
+ color: fill ?? stroke,
755
+ style: fill ? "fill" : hasStroke ? "stroke" : "fill",
756
+ strokeWidth: hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
757
+ antiAlias: true
758
+ }
759
+ );
760
+ }
761
+ case "ellipse": {
762
+ const stroke = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
763
+ const fill = isFill ? rgbaFromHexAndOpacity(node.fill, node.fillOpacity) ?? node.fill : void 0;
764
+ const cx = toNum(node.cx);
765
+ const cy = toNum(node.cy);
766
+ const rx = toNum(node.rx);
767
+ const ry = toNum(node.ry);
768
+ if (Math.abs(rx - ry) < 1e-6) {
769
+ return /* @__PURE__ */ jsxRuntime.jsx(
842
770
  reactNativeSkia.Circle,
843
771
  {
844
- cx: rotHandlePos.x,
845
- cy: rotHandlePos.y,
846
- r: handleR * 1.5,
847
- color: "#3b82f6",
848
- style: "stroke",
849
- strokeWidth: overlayStrokePx * 1.2,
772
+ cx,
773
+ cy,
774
+ r: rx,
775
+ color: fill ?? stroke,
776
+ style: fill ? "fill" : hasStroke ? "stroke" : "fill",
777
+ strokeWidth: hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
850
778
  antiAlias: true
851
779
  }
852
- )
853
- ] })
854
- ] });
855
- }, [selectedItems, showResizeHandles, rotateOffsetWorld, handleR]);
856
- const previewElements = react.useMemo(() => {
857
- if (!placementPreview) return null;
858
- const p = placementPreview;
859
- if (p.kind === "rect" || p.kind === "ellipse") {
860
- const r = normalizeRect(p.rect);
861
- return p.kind === "rect" ? /* @__PURE__ */ jsxRuntime.jsx(
862
- reactNativeSkia.Rect,
780
+ );
781
+ }
782
+ return /* @__PURE__ */ jsxRuntime.jsx(
783
+ reactNativeSkia.Oval,
863
784
  {
864
- x: r.x,
865
- y: r.y,
866
- width: r.width,
867
- height: r.height,
868
- color: "#64748b",
869
- style: "stroke",
870
- strokeWidth: overlayStrokePx,
785
+ x: cx - rx,
786
+ y: cy - ry,
787
+ width: rx * 2,
788
+ height: ry * 2,
789
+ color: fill ?? stroke,
790
+ style: fill ? "fill" : hasStroke ? "stroke" : "fill",
791
+ strokeWidth: hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
871
792
  antiAlias: true
872
793
  }
873
- ) : /* @__PURE__ */ jsxRuntime.jsx(
794
+ );
795
+ }
796
+ case "circle": {
797
+ const fill = rgbaFromHexAndOpacity(node.fill, node.fillOpacity) ?? node.fill;
798
+ const isCircleFill = fill && fill !== "none";
799
+ const strokeColor = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
800
+ return /* @__PURE__ */ jsxRuntime.jsx(
874
801
  reactNativeSkia.Circle,
875
802
  {
876
- cx: r.x + r.width / 2,
877
- cy: r.y + r.height / 2,
878
- r: Math.max(0, r.width / 2),
879
- color: "#64748b",
803
+ cx: toNum(node.cx),
804
+ cy: toNum(node.cy),
805
+ r: toNum(node.r),
806
+ color: isCircleFill ? fill : strokeColor ?? "black",
807
+ style: isCircleFill ? "fill" : "stroke",
808
+ strokeWidth: !isCircleFill && hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
809
+ antiAlias: true
810
+ }
811
+ );
812
+ }
813
+ case "line": {
814
+ const stroke = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
815
+ return /* @__PURE__ */ jsxRuntime.jsx(
816
+ reactNativeSkia.Line,
817
+ {
818
+ p1: reactNativeSkia.vec(toNum(node.x1), toNum(node.y1)),
819
+ p2: reactNativeSkia.vec(toNum(node.x2), toNum(node.y2)),
820
+ color: stroke ?? "black",
880
821
  style: "stroke",
881
- strokeWidth: overlayStrokePx,
822
+ strokeWidth: toNum(node.strokeWidth ?? "1"),
823
+ strokeCap: node.strokeLinecap === "round" ? "round" : "butt",
882
824
  antiAlias: true
883
825
  }
884
826
  );
885
827
  }
886
- if (p.kind === "marquee") {
887
- const r = normalizeRect(p.rect);
828
+ case "path": {
829
+ const stroke = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
830
+ const fill = isFill ? rgbaFromHexAndOpacity(node.fill, node.fillOpacity) ?? node.fill : void 0;
831
+ const style = fill && fill !== "none" ? "fill" : "stroke";
888
832
  return /* @__PURE__ */ jsxRuntime.jsx(
889
- reactNativeSkia.Rect,
833
+ reactNativeSkia.Path,
890
834
  {
891
- x: r.x,
892
- y: r.y,
893
- width: r.width,
894
- height: r.height,
895
- color: "rgba(59, 130, 246, 0.12)",
896
- style: "fill",
897
- antiAlias: true
898
- }
899
- );
900
- }
901
- if (p.kind === "line" || p.kind === "arrow") {
902
- const geometry = p.kind === "arrow" ? computeStraightArrowGeometry(
903
- { x1: p.start.x, y1: p.start.y, x2: p.end.x, y2: p.end.y },
904
- overlayStrokePx
905
- ) : null;
906
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
907
- /* @__PURE__ */ jsxRuntime.jsx(
908
- reactNativeSkia.Line,
909
- {
910
- p1: reactNativeSkia.vec(p.start.x, p.start.y),
911
- p2: reactNativeSkia.vec(geometry?.shaftEndX ?? p.end.x, geometry?.shaftEndY ?? p.end.y),
912
- color: "#64748b",
913
- style: "stroke",
914
- strokeWidth: overlayStrokePx,
915
- strokeCap: "round",
916
- antiAlias: true
917
- }
918
- ),
919
- p.kind === "arrow" && geometry ? /* @__PURE__ */ jsxRuntime.jsx(
920
- reactNativeSkia.Path,
921
- {
922
- path: `M ${geometry.headLeftX} ${geometry.headLeftY} L ${geometry.headTipX} ${geometry.headTipY} L ${geometry.headRightX} ${geometry.headRightY}`,
923
- color: "#64748b",
924
- style: "stroke",
925
- strokeWidth: overlayStrokePx,
926
- strokeCap: "round",
927
- strokeJoin: "round",
928
- antiAlias: true
929
- }
930
- ) : null
931
- ] });
932
- }
933
- if (p.kind === "stroke" && p.points.length >= 1) {
934
- const payload = computeFreehandSvgPayload(
935
- p.points,
936
- { stroke: "#64748b", strokeWidth: 3 },
937
- "draw",
938
- false
939
- );
940
- if (!payload) return null;
941
- if (payload.kind === "circle") {
942
- return /* @__PURE__ */ jsxRuntime.jsx(
943
- reactNativeSkia.Circle,
944
- {
945
- cx: payload.cx,
946
- cy: payload.cy,
947
- r: payload.r,
948
- color: payload.fill,
949
- style: "fill",
950
- antiAlias: true
951
- }
952
- );
953
- }
954
- if (payload.kind === "fillPath") {
955
- return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Path, { path: payload.d, color: payload.fill, style: "fill", antiAlias: true });
956
- }
957
- if (payload.kind === "strokePath") {
958
- return /* @__PURE__ */ jsxRuntime.jsx(
959
- reactNativeSkia.Path,
960
- {
961
- path: payload.d,
962
- color: "#64748b",
963
- style: "stroke",
964
- strokeWidth: 3,
965
- strokeCap: "round",
966
- strokeJoin: "round",
967
- antiAlias: true
968
- }
969
- );
970
- }
971
- return null;
972
- }
973
- return null;
974
- }, [placementPreview]);
975
- const eraserPreviewElements = react.useMemo(() => {
976
- if (eraserPreviewItems.length === 0) return null;
977
- return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: eraserPreviewItems.map((it) => {
978
- const b = normalizeRect(it.bounds);
979
- const cx = b.width / 2;
980
- const cy = b.height / 2;
981
- const t = skiaItemPlacementTransform(it.x, it.y, cx, cy, it.rotation ?? 0);
982
- return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Group, { transform: t, children: renderEraserSkeleton(it, overlayStrokePx) }, `erase-${it.id}`);
983
- }) });
984
- }, [eraserPreviewItems]);
985
- const eraserTrailElements = react.useMemo(() => {
986
- if (!eraserTrail || eraserTrail.length < 1) return null;
987
- const d = pointsToSmoothPathD(eraserTrail);
988
- if (!d)
989
- return eraserTrail[0] ? /* @__PURE__ */ jsxRuntime.jsx(
990
- reactNativeSkia.Circle,
991
- {
992
- cx: eraserTrail[0].x,
993
- cy: eraserTrail[0].y,
994
- r: Math.max(5 / z, 3),
995
- color: "#cbd5e1",
996
- style: "fill",
997
- antiAlias: true
998
- }
999
- ) : null;
1000
- return /* @__PURE__ */ jsxRuntime.jsx(
1001
- reactNativeSkia.Path,
1002
- {
1003
- path: d,
1004
- color: "#cbd5e1",
1005
- style: "stroke",
1006
- strokeWidth: Math.max(3.5 / z, overlayStrokePx),
1007
- strokeCap: "round",
1008
- strokeJoin: "round",
1009
- antiAlias: true
1010
- }
1011
- );
1012
- }, [eraserTrail, z]);
1013
- const laserTrailElements = react.useMemo(() => {
1014
- if (!laserTrail || laserTrail.length < 1) return null;
1015
- const d = pointsToSmoothPathD(laserTrail);
1016
- if (!d)
1017
- return laserTrail[0] ? /* @__PURE__ */ jsxRuntime.jsx(
1018
- reactNativeSkia.Circle,
1019
- {
1020
- cx: laserTrail[0].x,
1021
- cy: laserTrail[0].y,
1022
- r: Math.max(5 / z, 3),
1023
- color: "#f43f5e",
1024
- style: "fill",
1025
- antiAlias: true
1026
- }
1027
- ) : null;
1028
- return /* @__PURE__ */ jsxRuntime.jsx(
1029
- reactNativeSkia.Path,
1030
- {
1031
- path: d,
1032
- color: "#f43f5e",
1033
- style: "stroke",
1034
- strokeWidth: Math.max(4 / z, overlayStrokePx),
1035
- strokeCap: "round",
1036
- strokeJoin: "round",
1037
- antiAlias: true
1038
- }
1039
- );
1040
- }, [laserTrail, z]);
1041
- if (width <= 0 || height <= 0) return null;
1042
- return /* @__PURE__ */ jsxRuntime.jsx(
1043
- reactNativeSkia.Canvas,
1044
- {
1045
- style: {
1046
- position: "absolute",
1047
- top: 0,
1048
- left: 0,
1049
- width,
1050
- height
1051
- },
1052
- pointerEvents: "none",
1053
- children: /* @__PURE__ */ jsxRuntime.jsxs(reactNativeSkia.Group, { transform: camTransform, children: [
1054
- previewElements,
1055
- laserTrailElements,
1056
- eraserTrailElements,
1057
- eraserPreviewElements,
1058
- selectionElements
1059
- ] })
1060
- }
1061
- );
1062
- }
1063
-
1064
- // src/scene/spatial-cull.ts
1065
- var spatialIndexCache = /* @__PURE__ */ new WeakMap();
1066
- function cellKey(ix, iy) {
1067
- return `${ix},${iy}`;
1068
- }
1069
- function buildSpatialIndex(items, cellSize) {
1070
- const cached = spatialIndexCache.get(items);
1071
- if (cached && cached.cellSize === cellSize) {
1072
- return cached;
1073
- }
1074
- const aabbs = new Array(items.length);
1075
- const buckets = /* @__PURE__ */ new Map();
1076
- for (let index = 0; index < items.length; index += 1) {
1077
- const item = items[index];
1078
- if (!item) continue;
1079
- const aabb = boundsAabbForRotatedItem(item);
1080
- aabbs[index] = aabb;
1081
- const { minIx, maxIx, minIy, maxIy } = cellRangeForRect(aabb, cellSize);
1082
- for (let ix = minIx; ix <= maxIx; ix += 1) {
1083
- for (let iy = minIy; iy <= maxIy; iy += 1) {
1084
- const key = cellKey(ix, iy);
1085
- let bucket = buckets.get(key);
1086
- if (!bucket) {
1087
- bucket = [];
1088
- buckets.set(key, bucket);
1089
- }
1090
- bucket.push(index);
1091
- }
1092
- }
1093
- }
1094
- const next = {
1095
- cellSize,
1096
- aabbs,
1097
- buckets
1098
- };
1099
- spatialIndexCache.set(items, next);
1100
- return next;
1101
- }
1102
- function cellRangeForRect(r, cellSize) {
1103
- const n = normalizeRect(r);
1104
- const x1 = n.x + n.width;
1105
- const y1 = n.y + n.height;
1106
- const minIx = Math.floor(n.x / cellSize);
1107
- const maxIx = Math.max(minIx, Math.ceil(x1 / cellSize) - 1);
1108
- const minIy = Math.floor(n.y / cellSize);
1109
- const maxIy = Math.max(minIy, Math.ceil(y1 / cellSize) - 1);
1110
- return { minIx, maxIx, minIy, maxIy };
1111
- }
1112
- function cullItemsByViewportSpatial(items, visibleWorld, cellSize) {
1113
- const { aabbs, buckets } = buildSpatialIndex(items, cellSize);
1114
- const vr = cellRangeForRect(visibleWorld, cellSize);
1115
- const seen = /* @__PURE__ */ new Set();
1116
- const outIndices = [];
1117
- for (let ix = vr.minIx; ix <= vr.maxIx; ix++) {
1118
- for (let iy = vr.minIy; iy <= vr.maxIy; iy++) {
1119
- const bucket = buckets.get(cellKey(ix, iy));
1120
- if (!bucket) {
1121
- continue;
1122
- }
1123
- for (const index of bucket) {
1124
- if (seen.has(index)) {
1125
- continue;
1126
- }
1127
- seen.add(index);
1128
- const aabb = aabbs[index];
1129
- if (aabb && rectsIntersect(aabb, visibleWorld)) {
1130
- outIndices.push(index);
1131
- }
1132
- }
1133
- }
1134
- }
1135
- outIndices.sort((a, b) => a - b);
1136
- return outIndices.map((index) => items[index]).filter((item) => item != null);
1137
- }
1138
-
1139
- // src/scene/cull.ts
1140
- var SPATIAL_MIN_ITEMS = 400;
1141
- var SPATIAL_CELL_SIZE = 256;
1142
- function cullItemsLinear(items, visibleWorld) {
1143
- return items.filter(
1144
- (item) => rectsIntersect(boundsAabbForCull(item), visibleWorld)
1145
- );
1146
- }
1147
- function boundsAabbForCull(item) {
1148
- return boundsAabbForRotatedItem(item);
1149
- }
1150
- function cullItemsByViewport(items, visibleWorld) {
1151
- if (items.length < SPATIAL_MIN_ITEMS) {
1152
- return cullItemsLinear(items, visibleWorld);
1153
- }
1154
- return cullItemsByViewportSpatial(items, visibleWorld, SPATIAL_CELL_SIZE);
1155
- }
1156
- function rgbaFromHexAndOpacity(hex, opacity) {
1157
- if (!hex) return hex;
1158
- if (opacity == null || opacity >= 1) return hex;
1159
- const r = parseInt(hex.slice(1, 3), 16);
1160
- const g = parseInt(hex.slice(3, 5), 16);
1161
- const b = parseInt(hex.slice(5, 7), 16);
1162
- if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return hex;
1163
- return `rgba(${r},${g},${b},${opacity})`;
1164
- }
1165
- function toNum(v) {
1166
- return typeof v === "number" ? v : Number(v) || 0;
1167
- }
1168
- function SvgNodeRenderer({ nodes }) {
1169
- return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: nodes.map((node, i) => /* @__PURE__ */ jsxRuntime.jsx(SvgNodeItem, { node }, i)) });
1170
- }
1171
- function SvgNodeItem({ node }) {
1172
- const isFill = node.fill !== "none";
1173
- const hasStroke = node.stroke != null && node.stroke !== "none";
1174
- switch (node.kind) {
1175
- case "rect": {
1176
- const stroke = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
1177
- const fill = isFill ? rgbaFromHexAndOpacity(node.fill, node.fillOpacity) ?? node.fill : void 0;
1178
- const r = node.rx != null ? toNum(node.rx) : 0;
1179
- const w = toNum(node.width);
1180
- const h = toNum(node.height);
1181
- if (r > 0) {
1182
- return /* @__PURE__ */ jsxRuntime.jsx(
1183
- reactNativeSkia.RoundedRect,
1184
- {
1185
- x: 0,
1186
- y: 0,
1187
- width: w,
1188
- height: h,
1189
- r,
1190
- color: fill ?? stroke,
1191
- style: fill ? "fill" : hasStroke ? "stroke" : "fill",
1192
- strokeWidth: hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
1193
- antiAlias: true
1194
- }
1195
- );
1196
- }
1197
- return /* @__PURE__ */ jsxRuntime.jsx(
1198
- reactNativeSkia.Rect,
1199
- {
1200
- x: 0,
1201
- y: 0,
1202
- width: w,
1203
- height: h,
1204
- color: fill ?? stroke,
1205
- style: fill ? "fill" : hasStroke ? "stroke" : "fill",
1206
- strokeWidth: hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
1207
- antiAlias: true
1208
- }
1209
- );
1210
- }
1211
- case "ellipse": {
1212
- const stroke = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
1213
- const fill = isFill ? rgbaFromHexAndOpacity(node.fill, node.fillOpacity) ?? node.fill : void 0;
1214
- const cx = toNum(node.cx);
1215
- const cy = toNum(node.cy);
1216
- const rx = toNum(node.rx);
1217
- const ry = toNum(node.ry);
1218
- if (Math.abs(rx - ry) < 1e-6) {
1219
- return /* @__PURE__ */ jsxRuntime.jsx(
1220
- reactNativeSkia.Circle,
1221
- {
1222
- cx,
1223
- cy,
1224
- r: rx,
1225
- color: fill ?? stroke,
1226
- style: fill ? "fill" : hasStroke ? "stroke" : "fill",
1227
- strokeWidth: hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
1228
- antiAlias: true
1229
- }
1230
- );
1231
- }
1232
- return /* @__PURE__ */ jsxRuntime.jsx(
1233
- reactNativeSkia.Oval,
1234
- {
1235
- x: cx - rx,
1236
- y: cy - ry,
1237
- width: rx * 2,
1238
- height: ry * 2,
1239
- color: fill ?? stroke,
1240
- style: fill ? "fill" : hasStroke ? "stroke" : "fill",
1241
- strokeWidth: hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
1242
- antiAlias: true
1243
- }
1244
- );
1245
- }
1246
- case "circle": {
1247
- const fill = rgbaFromHexAndOpacity(node.fill, node.fillOpacity) ?? node.fill;
1248
- const isCircleFill = fill && fill !== "none";
1249
- const strokeColor = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
1250
- return /* @__PURE__ */ jsxRuntime.jsx(
1251
- reactNativeSkia.Circle,
1252
- {
1253
- cx: toNum(node.cx),
1254
- cy: toNum(node.cy),
1255
- r: toNum(node.r),
1256
- color: isCircleFill ? fill : strokeColor ?? "black",
1257
- style: isCircleFill ? "fill" : "stroke",
1258
- strokeWidth: !isCircleFill && hasStroke ? toNum(node.strokeWidth ?? "1") : void 0,
1259
- antiAlias: true
1260
- }
1261
- );
1262
- }
1263
- case "line": {
1264
- const stroke = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
1265
- return /* @__PURE__ */ jsxRuntime.jsx(
1266
- reactNativeSkia.Line,
1267
- {
1268
- p1: reactNativeSkia.vec(toNum(node.x1), toNum(node.y1)),
1269
- p2: reactNativeSkia.vec(toNum(node.x2), toNum(node.y2)),
1270
- color: stroke ?? "black",
1271
- style: "stroke",
1272
- strokeWidth: toNum(node.strokeWidth ?? "1"),
1273
- strokeCap: node.strokeLinecap === "round" ? "round" : "butt",
1274
- antiAlias: true
1275
- }
1276
- );
1277
- }
1278
- case "path": {
1279
- const stroke = rgbaFromHexAndOpacity(node.stroke, node.strokeOpacity);
1280
- const fill = isFill ? rgbaFromHexAndOpacity(node.fill, node.fillOpacity) ?? node.fill : void 0;
1281
- const style = fill && fill !== "none" ? "fill" : "stroke";
1282
- return /* @__PURE__ */ jsxRuntime.jsx(
1283
- reactNativeSkia.Path,
1284
- {
1285
- path: node.d,
1286
- color: style === "fill" ? fill ?? stroke : stroke ?? "black",
1287
- style,
1288
- strokeWidth: style === "stroke" ? toNum(node.strokeWidth ?? "1") : void 0,
1289
- strokeCap: node.strokeLinecap === "round" ? "round" : "butt",
1290
- strokeJoin: node.strokeLinejoin === "round" ? "round" : "miter",
1291
- fillType: node.fillRule === "evenodd" ? "evenOdd" : "winding",
835
+ path: node.d,
836
+ color: style === "fill" ? fill ?? stroke : stroke ?? "black",
837
+ style,
838
+ strokeWidth: style === "stroke" ? toNum(node.strokeWidth ?? "1") : void 0,
839
+ strokeCap: node.strokeLinecap === "round" ? "round" : "butt",
840
+ strokeJoin: node.strokeLinejoin === "round" ? "round" : "miter",
841
+ fillType: node.fillRule === "evenodd" ? "evenOdd" : "winding",
1292
842
  antiAlias: true
1293
843
  }
1294
844
  );
@@ -1634,188 +1184,576 @@ function NativeShapeRenderer({ item }) {
1634
1184
  const ry = Math.max(0, h / 2);
1635
1185
  if (Math.abs(rx - ry) < 1e-9) {
1636
1186
  return /* @__PURE__ */ jsxRuntime.jsx(
1637
- reactNativeSkia.Circle,
1187
+ reactNativeSkia.Circle,
1188
+ {
1189
+ cx: rx,
1190
+ cy: ry,
1191
+ r: rx,
1192
+ color: style.stroke,
1193
+ style: "stroke",
1194
+ strokeWidth: style.strokeWidth,
1195
+ antiAlias: true
1196
+ }
1197
+ );
1198
+ }
1199
+ return /* @__PURE__ */ jsxRuntime.jsx(
1200
+ reactNativeSkia.Circle,
1201
+ {
1202
+ cx: rx,
1203
+ cy: ry,
1204
+ r: Math.min(rx, ry),
1205
+ color: style.stroke,
1206
+ style: "stroke",
1207
+ strokeWidth: style.strokeWidth,
1208
+ antiAlias: true
1209
+ }
1210
+ );
1211
+ }
1212
+ if ((k === "line" || k === "arrow") && item.line) {
1213
+ const ln = item.line;
1214
+ const color = rgba(style.stroke, style.strokeOpacity);
1215
+ if (k === "line") {
1216
+ return /* @__PURE__ */ jsxRuntime.jsx(
1217
+ reactNativeSkia.Line,
1218
+ {
1219
+ p1: reactNativeSkia.vec(ln.x1, ln.y1),
1220
+ p2: reactNativeSkia.vec(ln.x2, ln.y2),
1221
+ color,
1222
+ style: "stroke",
1223
+ strokeWidth: style.strokeWidth,
1224
+ strokeCap: "round",
1225
+ antiAlias: true
1226
+ }
1227
+ );
1228
+ }
1229
+ const dx = ln.x2 - ln.x1;
1230
+ const dy = ln.y2 - ln.y1;
1231
+ const len = Math.hypot(dx, dy);
1232
+ if (len < 1e-6) {
1233
+ return /* @__PURE__ */ jsxRuntime.jsx(
1234
+ reactNativeSkia.Line,
1235
+ {
1236
+ p1: reactNativeSkia.vec(ln.x1, ln.y1),
1237
+ p2: reactNativeSkia.vec(ln.x2, ln.y2),
1238
+ color,
1239
+ style: "stroke",
1240
+ strokeWidth: style.strokeWidth,
1241
+ strokeCap: "round",
1242
+ antiAlias: true
1243
+ }
1244
+ );
1245
+ }
1246
+ const geometry = computeStraightArrowGeometry(ln, style.strokeWidth);
1247
+ if (!geometry) {
1248
+ return /* @__PURE__ */ jsxRuntime.jsx(
1249
+ reactNativeSkia.Line,
1250
+ {
1251
+ p1: reactNativeSkia.vec(ln.x1, ln.y1),
1252
+ p2: reactNativeSkia.vec(ln.x2, ln.y2),
1253
+ color,
1254
+ style: "stroke",
1255
+ strokeWidth: style.strokeWidth,
1256
+ strokeCap: "round",
1257
+ antiAlias: true
1258
+ }
1259
+ );
1260
+ }
1261
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1262
+ /* @__PURE__ */ jsxRuntime.jsx(
1263
+ reactNativeSkia.Line,
1264
+ {
1265
+ p1: reactNativeSkia.vec(ln.x1, ln.y1),
1266
+ p2: reactNativeSkia.vec(geometry.shaftEndX, geometry.shaftEndY),
1267
+ color,
1268
+ style: "stroke",
1269
+ strokeWidth: style.strokeWidth,
1270
+ strokeCap: "round",
1271
+ antiAlias: true
1272
+ }
1273
+ ),
1274
+ /* @__PURE__ */ jsxRuntime.jsx(
1275
+ reactNativeSkia.Path,
1276
+ {
1277
+ path: `M ${geometry.headLeftX} ${geometry.headLeftY} L ${geometry.headTipX} ${geometry.headTipY} L ${geometry.headRightX} ${geometry.headRightY}`,
1278
+ color,
1279
+ style: "stroke",
1280
+ strokeWidth: style.strokeWidth,
1281
+ strokeCap: "round",
1282
+ strokeJoin: "round",
1283
+ antiAlias: true
1284
+ }
1285
+ )
1286
+ ] });
1287
+ }
1288
+ if (k === "text" && item.text !== void 0) {
1289
+ const fs = item.textFontSize ?? 16;
1290
+ const color = rgba(style.stroke, style.strokeOpacity);
1291
+ const lines = item.text.split("\n");
1292
+ const font = reactNativeSkia.matchFont({ fontSize: fs });
1293
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: lines.map((line, i) => /* @__PURE__ */ jsxRuntime.jsx(
1294
+ reactNativeSkia.Text,
1295
+ {
1296
+ x: 0,
1297
+ y: fs + i * fs * 1.2,
1298
+ text: line,
1299
+ color,
1300
+ font
1301
+ },
1302
+ i
1303
+ )) });
1304
+ }
1305
+ if ((k === "draw" || k === "pencil" || k === "brush" || k === "marker") && item.pathPointsLocal && item.pathPointsLocal.length > 0) {
1306
+ const payload = computeFreehandSvgPayload(item.pathPointsLocal, style, k);
1307
+ if (!payload) return null;
1308
+ const color = rgba(style.stroke, style.strokeOpacity);
1309
+ if (payload.kind === "circle") {
1310
+ return /* @__PURE__ */ jsxRuntime.jsx(
1311
+ reactNativeSkia.Circle,
1312
+ {
1313
+ cx: payload.cx,
1314
+ cy: payload.cy,
1315
+ r: payload.r,
1316
+ color: rgba(payload.fill, payload.fillOpacity),
1317
+ style: "fill",
1318
+ antiAlias: true
1319
+ }
1320
+ );
1321
+ }
1322
+ if (payload.kind === "fillPath") {
1323
+ return /* @__PURE__ */ jsxRuntime.jsx(
1324
+ reactNativeSkia.Path,
1638
1325
  {
1639
- cx: rx,
1640
- cy: ry,
1641
- r: rx,
1642
- color: style.stroke,
1643
- style: "stroke",
1644
- strokeWidth: style.strokeWidth,
1326
+ path: payload.d,
1327
+ color: rgba(payload.fill, payload.fillOpacity),
1328
+ style: "fill",
1329
+ fillType: "winding",
1645
1330
  antiAlias: true
1646
1331
  }
1647
1332
  );
1648
1333
  }
1649
1334
  return /* @__PURE__ */ jsxRuntime.jsx(
1650
- reactNativeSkia.Circle,
1335
+ reactNativeSkia.Path,
1651
1336
  {
1652
- cx: rx,
1653
- cy: ry,
1654
- r: Math.min(rx, ry),
1655
- color: style.stroke,
1337
+ path: payload.d,
1338
+ color,
1656
1339
  style: "stroke",
1657
- strokeWidth: style.strokeWidth,
1340
+ strokeWidth: payload.strokeWidth,
1341
+ strokeCap: "round",
1342
+ strokeJoin: "round",
1658
1343
  antiAlias: true
1659
1344
  }
1660
1345
  );
1661
1346
  }
1662
- if ((k === "line" || k === "arrow") && item.line) {
1663
- const ln = item.line;
1664
- const color = rgba(style.stroke, style.strokeOpacity);
1665
- if (k === "line") {
1666
- return /* @__PURE__ */ jsxRuntime.jsx(
1667
- reactNativeSkia.Line,
1347
+ if (k === "draw") {
1348
+ const { w, h } = localBounds(item.bounds);
1349
+ const r = Math.min(w, h) / 2;
1350
+ return /* @__PURE__ */ jsxRuntime.jsx(
1351
+ reactNativeSkia.Circle,
1352
+ {
1353
+ cx: Math.max(0, w) / 2,
1354
+ cy: Math.max(0, h) / 2,
1355
+ r: Math.max(0, r),
1356
+ color: rgba(style.stroke, style.strokeOpacity),
1357
+ style: "fill",
1358
+ antiAlias: true
1359
+ }
1360
+ );
1361
+ }
1362
+ if (k === "image" || k === "custom" || item.childrenSvg) {
1363
+ const nodes = parseSvgFragment(item.childrenSvg);
1364
+ if (nodes.length > 0) {
1365
+ return /* @__PURE__ */ jsxRuntime.jsx(SvgNodeRenderer, { nodes });
1366
+ }
1367
+ }
1368
+ return null;
1369
+ }
1370
+ var HANDLE_ORDER = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
1371
+ var ERASER_PREVIEW_OPACITY = 0.3;
1372
+ function pointsToSmoothPathD(points) {
1373
+ if (points.length < 2) return null;
1374
+ const d = smoothFreehandPointsToPathD(points);
1375
+ return d || null;
1376
+ }
1377
+ function NativeInteractionOverlay({
1378
+ camera,
1379
+ width,
1380
+ height,
1381
+ selectedItems,
1382
+ showResizeHandles,
1383
+ placementPreview,
1384
+ eraserTrail,
1385
+ laserTrail,
1386
+ eraserPreviewItems = []
1387
+ }) {
1388
+ const z = camera.zoom;
1389
+ const camTransform = skiaCameraTransform(z, camera.x, camera.y);
1390
+ const handleR = 5 / z;
1391
+ const overlayStrokePx = 1.25;
1392
+ const rotateOffsetWorld = 24 / z;
1393
+ const selectionElements = react.useMemo(() => {
1394
+ if (selectedItems.length === 0) return null;
1395
+ const single = selectedItems.length === 1 ? selectedItems[0] : void 0;
1396
+ const bSingle = single ? normalizeRect(single.bounds) : null;
1397
+ const rotSingle = single?.rotation ?? 0;
1398
+ const rotHandlePos = showResizeHandles && bSingle && single ? getRotationHandleWorldPosition(bSingle, rotSingle, rotateOffsetWorld) : null;
1399
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1400
+ selectedItems.map((it) => {
1401
+ const b = normalizeRect(it.bounds);
1402
+ const cx = b.width / 2;
1403
+ const cy = b.height / 2;
1404
+ const t = skiaItemPlacementTransform(it.x, it.y, cx, cy, it.rotation ?? 0);
1405
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Group, { transform: t, children: /* @__PURE__ */ jsxRuntime.jsx(
1406
+ reactNativeSkia.Rect,
1407
+ {
1408
+ x: 0,
1409
+ y: 0,
1410
+ width: b.width,
1411
+ height: b.height,
1412
+ color: "#3b82f6",
1413
+ style: "stroke",
1414
+ strokeWidth: overlayStrokePx,
1415
+ antiAlias: true
1416
+ }
1417
+ ) }, it.id);
1418
+ }),
1419
+ showResizeHandles && bSingle && single && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1420
+ HANDLE_ORDER.map((hid) => {
1421
+ const p = getHandleWorldPositionRotated(bSingle, hid, rotSingle);
1422
+ return /* @__PURE__ */ jsxRuntime.jsx(
1423
+ reactNativeSkia.Circle,
1424
+ {
1425
+ cx: p.x,
1426
+ cy: p.y,
1427
+ r: handleR,
1428
+ color: "#ffffff",
1429
+ style: "fill",
1430
+ antiAlias: true
1431
+ },
1432
+ hid
1433
+ );
1434
+ }),
1435
+ rotHandlePos && /* @__PURE__ */ jsxRuntime.jsx(
1436
+ reactNativeSkia.Circle,
1437
+ {
1438
+ cx: rotHandlePos.x,
1439
+ cy: rotHandlePos.y,
1440
+ r: handleR * 1.5,
1441
+ color: "#3b82f6",
1442
+ style: "stroke",
1443
+ strokeWidth: overlayStrokePx * 1.2,
1444
+ antiAlias: true
1445
+ }
1446
+ )
1447
+ ] })
1448
+ ] });
1449
+ }, [selectedItems, showResizeHandles, rotateOffsetWorld, handleR]);
1450
+ const previewElements = react.useMemo(() => {
1451
+ if (!placementPreview) return null;
1452
+ const p = placementPreview;
1453
+ if (p.kind === "rect" || p.kind === "ellipse") {
1454
+ const r = normalizeRect(p.rect);
1455
+ return p.kind === "rect" ? /* @__PURE__ */ jsxRuntime.jsx(
1456
+ reactNativeSkia.Rect,
1668
1457
  {
1669
- p1: reactNativeSkia.vec(ln.x1, ln.y1),
1670
- p2: reactNativeSkia.vec(ln.x2, ln.y2),
1671
- color,
1458
+ x: r.x,
1459
+ y: r.y,
1460
+ width: r.width,
1461
+ height: r.height,
1462
+ color: "#64748b",
1672
1463
  style: "stroke",
1673
- strokeWidth: style.strokeWidth,
1674
- strokeCap: "round",
1464
+ strokeWidth: overlayStrokePx,
1465
+ antiAlias: true
1466
+ }
1467
+ ) : /* @__PURE__ */ jsxRuntime.jsx(
1468
+ reactNativeSkia.Circle,
1469
+ {
1470
+ cx: r.x + r.width / 2,
1471
+ cy: r.y + r.height / 2,
1472
+ r: Math.max(0, r.width / 2),
1473
+ color: "#64748b",
1474
+ style: "stroke",
1475
+ strokeWidth: overlayStrokePx,
1675
1476
  antiAlias: true
1676
1477
  }
1677
1478
  );
1678
1479
  }
1679
- const dx = ln.x2 - ln.x1;
1680
- const dy = ln.y2 - ln.y1;
1681
- const len = Math.hypot(dx, dy);
1682
- if (len < 1e-6) {
1480
+ if (p.kind === "marquee") {
1481
+ const r = normalizeRect(p.rect);
1683
1482
  return /* @__PURE__ */ jsxRuntime.jsx(
1684
- reactNativeSkia.Line,
1483
+ reactNativeSkia.Rect,
1685
1484
  {
1686
- p1: reactNativeSkia.vec(ln.x1, ln.y1),
1687
- p2: reactNativeSkia.vec(ln.x2, ln.y2),
1688
- color,
1689
- style: "stroke",
1690
- strokeWidth: style.strokeWidth,
1691
- strokeCap: "round",
1485
+ x: r.x,
1486
+ y: r.y,
1487
+ width: r.width,
1488
+ height: r.height,
1489
+ color: "rgba(59, 130, 246, 0.12)",
1490
+ style: "fill",
1692
1491
  antiAlias: true
1693
1492
  }
1694
1493
  );
1695
1494
  }
1696
- const geometry = computeStraightArrowGeometry(ln, style.strokeWidth);
1697
- if (!geometry) {
1495
+ if (p.kind === "line" || p.kind === "arrow") {
1496
+ const geometry = p.kind === "arrow" ? computeStraightArrowGeometry(
1497
+ { x1: p.start.x, y1: p.start.y, x2: p.end.x, y2: p.end.y },
1498
+ overlayStrokePx
1499
+ ) : null;
1500
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1501
+ /* @__PURE__ */ jsxRuntime.jsx(
1502
+ reactNativeSkia.Line,
1503
+ {
1504
+ p1: reactNativeSkia.vec(p.start.x, p.start.y),
1505
+ p2: reactNativeSkia.vec(geometry?.shaftEndX ?? p.end.x, geometry?.shaftEndY ?? p.end.y),
1506
+ color: "#64748b",
1507
+ style: "stroke",
1508
+ strokeWidth: overlayStrokePx,
1509
+ strokeCap: "round",
1510
+ antiAlias: true
1511
+ }
1512
+ ),
1513
+ p.kind === "arrow" && geometry ? /* @__PURE__ */ jsxRuntime.jsx(
1514
+ reactNativeSkia.Path,
1515
+ {
1516
+ path: `M ${geometry.headLeftX} ${geometry.headLeftY} L ${geometry.headTipX} ${geometry.headTipY} L ${geometry.headRightX} ${geometry.headRightY}`,
1517
+ color: "#64748b",
1518
+ style: "stroke",
1519
+ strokeWidth: overlayStrokePx,
1520
+ strokeCap: "round",
1521
+ strokeJoin: "round",
1522
+ antiAlias: true
1523
+ }
1524
+ ) : null
1525
+ ] });
1526
+ }
1527
+ if (p.kind === "stroke" && p.points.length >= 1) {
1528
+ const payload = computeFreehandSvgPayload(
1529
+ p.points,
1530
+ { stroke: "#64748b", strokeWidth: 3 },
1531
+ "draw",
1532
+ false
1533
+ );
1534
+ if (!payload) return null;
1535
+ if (payload.kind === "circle") {
1536
+ return /* @__PURE__ */ jsxRuntime.jsx(
1537
+ reactNativeSkia.Circle,
1538
+ {
1539
+ cx: payload.cx,
1540
+ cy: payload.cy,
1541
+ r: payload.r,
1542
+ color: payload.fill,
1543
+ style: "fill",
1544
+ antiAlias: true
1545
+ }
1546
+ );
1547
+ }
1548
+ if (payload.kind === "fillPath") {
1549
+ return /* @__PURE__ */ jsxRuntime.jsx(reactNativeSkia.Path, { path: payload.d, color: payload.fill, style: "fill", antiAlias: true });
1550
+ }
1551
+ if (payload.kind === "strokePath") {
1552
+ return /* @__PURE__ */ jsxRuntime.jsx(
1553
+ reactNativeSkia.Path,
1554
+ {
1555
+ path: payload.d,
1556
+ color: "#64748b",
1557
+ style: "stroke",
1558
+ strokeWidth: 3,
1559
+ strokeCap: "round",
1560
+ strokeJoin: "round",
1561
+ antiAlias: true
1562
+ }
1563
+ );
1564
+ }
1565
+ return null;
1566
+ }
1567
+ return null;
1568
+ }, [placementPreview]);
1569
+ const eraserPreviewElements = react.useMemo(() => {
1570
+ if (eraserPreviewItems.length === 0) return null;
1571
+ return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: eraserPreviewItems.map((it) => {
1572
+ const b = normalizeRect(it.bounds);
1573
+ const cx = b.width / 2;
1574
+ const cy = b.height / 2;
1575
+ const t = skiaItemPlacementTransform(it.x, it.y, cx, cy, it.rotation ?? 0);
1698
1576
  return /* @__PURE__ */ jsxRuntime.jsx(
1699
- reactNativeSkia.Line,
1577
+ reactNativeSkia.Group,
1700
1578
  {
1701
- p1: reactNativeSkia.vec(ln.x1, ln.y1),
1702
- p2: reactNativeSkia.vec(ln.x2, ln.y2),
1703
- color,
1704
- style: "stroke",
1705
- strokeWidth: style.strokeWidth,
1706
- strokeCap: "round",
1707
- antiAlias: true
1708
- }
1579
+ transform: t,
1580
+ opacity: ERASER_PREVIEW_OPACITY,
1581
+ children: /* @__PURE__ */ jsxRuntime.jsx(NativeShapeRenderer, { item: it })
1582
+ },
1583
+ `erase-${it.id}`
1709
1584
  );
1710
- }
1711
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1712
- /* @__PURE__ */ jsxRuntime.jsx(
1713
- reactNativeSkia.Line,
1714
- {
1715
- p1: reactNativeSkia.vec(ln.x1, ln.y1),
1716
- p2: reactNativeSkia.vec(geometry.shaftEndX, geometry.shaftEndY),
1717
- color,
1718
- style: "stroke",
1719
- strokeWidth: style.strokeWidth,
1720
- strokeCap: "round",
1721
- antiAlias: true
1722
- }
1723
- ),
1724
- /* @__PURE__ */ jsxRuntime.jsx(
1725
- reactNativeSkia.Path,
1726
- {
1727
- path: `M ${geometry.headLeftX} ${geometry.headLeftY} L ${geometry.headTipX} ${geometry.headTipY} L ${geometry.headRightX} ${geometry.headRightY}`,
1728
- color,
1729
- style: "stroke",
1730
- strokeWidth: style.strokeWidth,
1731
- strokeCap: "round",
1732
- strokeJoin: "round",
1733
- antiAlias: true
1734
- }
1735
- )
1736
- ] });
1737
- }
1738
- if (k === "text" && item.text !== void 0) {
1739
- const fs = item.textFontSize ?? 16;
1740
- const color = rgba(style.stroke, style.strokeOpacity);
1741
- const lines = item.text.split("\n");
1742
- const font = reactNativeSkia.matchFont({ fontSize: fs });
1743
- return /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: lines.map((line, i) => /* @__PURE__ */ jsxRuntime.jsx(
1744
- reactNativeSkia.Text,
1745
- {
1746
- x: 0,
1747
- y: fs + i * fs * 1.2,
1748
- text: line,
1749
- color,
1750
- font
1751
- },
1752
- i
1753
- )) });
1754
- }
1755
- if ((k === "draw" || k === "pencil" || k === "brush" || k === "marker") && item.pathPointsLocal && item.pathPointsLocal.length > 0) {
1756
- const payload = computeFreehandSvgPayload(item.pathPointsLocal, style, k);
1757
- if (!payload) return null;
1758
- const color = rgba(style.stroke, style.strokeOpacity);
1759
- if (payload.kind === "circle") {
1760
- return /* @__PURE__ */ jsxRuntime.jsx(
1585
+ }) });
1586
+ }, [eraserPreviewItems]);
1587
+ const eraserTrailElements = react.useMemo(() => {
1588
+ if (!eraserTrail || eraserTrail.length < 1) return null;
1589
+ const d = pointsToSmoothPathD(eraserTrail);
1590
+ if (!d)
1591
+ return eraserTrail[0] ? /* @__PURE__ */ jsxRuntime.jsx(
1761
1592
  reactNativeSkia.Circle,
1762
1593
  {
1763
- cx: payload.cx,
1764
- cy: payload.cy,
1765
- r: payload.r,
1766
- color: rgba(payload.fill, payload.fillOpacity),
1594
+ cx: eraserTrail[0].x,
1595
+ cy: eraserTrail[0].y,
1596
+ r: Math.max(5 / z, 3),
1597
+ color: "#cbd5e1",
1767
1598
  style: "fill",
1768
1599
  antiAlias: true
1769
1600
  }
1770
- );
1771
- }
1772
- if (payload.kind === "fillPath") {
1773
- return /* @__PURE__ */ jsxRuntime.jsx(
1774
- reactNativeSkia.Path,
1601
+ ) : null;
1602
+ return /* @__PURE__ */ jsxRuntime.jsx(
1603
+ reactNativeSkia.Path,
1604
+ {
1605
+ path: d,
1606
+ color: "#cbd5e1",
1607
+ style: "stroke",
1608
+ strokeWidth: Math.max(3.5 / z, overlayStrokePx),
1609
+ strokeCap: "round",
1610
+ strokeJoin: "round",
1611
+ antiAlias: true
1612
+ }
1613
+ );
1614
+ }, [eraserTrail, z]);
1615
+ const laserTrailElements = react.useMemo(() => {
1616
+ if (!laserTrail || laserTrail.length < 1) return null;
1617
+ const d = pointsToSmoothPathD(laserTrail);
1618
+ if (!d)
1619
+ return laserTrail[0] ? /* @__PURE__ */ jsxRuntime.jsx(
1620
+ reactNativeSkia.Circle,
1775
1621
  {
1776
- path: payload.d,
1777
- color: rgba(payload.fill, payload.fillOpacity),
1622
+ cx: laserTrail[0].x,
1623
+ cy: laserTrail[0].y,
1624
+ r: Math.max(5 / z, 3),
1625
+ color: "#f43f5e",
1778
1626
  style: "fill",
1779
- fillType: "winding",
1780
1627
  antiAlias: true
1781
1628
  }
1782
- );
1783
- }
1629
+ ) : null;
1784
1630
  return /* @__PURE__ */ jsxRuntime.jsx(
1785
1631
  reactNativeSkia.Path,
1786
1632
  {
1787
- path: payload.d,
1788
- color,
1633
+ path: d,
1634
+ color: "#f43f5e",
1789
1635
  style: "stroke",
1790
- strokeWidth: payload.strokeWidth,
1636
+ strokeWidth: Math.max(4 / z, overlayStrokePx),
1791
1637
  strokeCap: "round",
1792
1638
  strokeJoin: "round",
1793
1639
  antiAlias: true
1794
1640
  }
1795
1641
  );
1642
+ }, [laserTrail, z]);
1643
+ if (width <= 0 || height <= 0) return null;
1644
+ return /* @__PURE__ */ jsxRuntime.jsx(
1645
+ reactNativeSkia.Canvas,
1646
+ {
1647
+ style: {
1648
+ position: "absolute",
1649
+ top: 0,
1650
+ left: 0,
1651
+ width,
1652
+ height
1653
+ },
1654
+ pointerEvents: "none",
1655
+ children: /* @__PURE__ */ jsxRuntime.jsxs(reactNativeSkia.Group, { transform: camTransform, children: [
1656
+ previewElements,
1657
+ laserTrailElements,
1658
+ eraserTrailElements,
1659
+ eraserPreviewElements,
1660
+ selectionElements
1661
+ ] })
1662
+ }
1663
+ );
1664
+ }
1665
+
1666
+ // src/scene/spatial-cull.ts
1667
+ var spatialIndexCache = /* @__PURE__ */ new WeakMap();
1668
+ function cellKey(ix, iy) {
1669
+ return `${ix},${iy}`;
1670
+ }
1671
+ function buildSpatialIndex(items, cellSize) {
1672
+ const cached = spatialIndexCache.get(items);
1673
+ if (cached && cached.cellSize === cellSize) {
1674
+ return cached;
1796
1675
  }
1797
- if (k === "draw") {
1798
- const { w, h } = localBounds(item.bounds);
1799
- const r = Math.min(w, h) / 2;
1800
- return /* @__PURE__ */ jsxRuntime.jsx(
1801
- reactNativeSkia.Circle,
1802
- {
1803
- cx: Math.max(0, w) / 2,
1804
- cy: Math.max(0, h) / 2,
1805
- r: Math.max(0, r),
1806
- color: rgba(style.stroke, style.strokeOpacity),
1807
- style: "fill",
1808
- antiAlias: true
1676
+ const aabbs = new Array(items.length);
1677
+ const buckets = /* @__PURE__ */ new Map();
1678
+ for (let index = 0; index < items.length; index += 1) {
1679
+ const item = items[index];
1680
+ if (!item) continue;
1681
+ const aabb = boundsAabbForRotatedItem(item);
1682
+ aabbs[index] = aabb;
1683
+ const { minIx, maxIx, minIy, maxIy } = cellRangeForRect(aabb, cellSize);
1684
+ for (let ix = minIx; ix <= maxIx; ix += 1) {
1685
+ for (let iy = minIy; iy <= maxIy; iy += 1) {
1686
+ const key = cellKey(ix, iy);
1687
+ let bucket = buckets.get(key);
1688
+ if (!bucket) {
1689
+ bucket = [];
1690
+ buckets.set(key, bucket);
1691
+ }
1692
+ bucket.push(index);
1809
1693
  }
1810
- );
1694
+ }
1811
1695
  }
1812
- if (k === "image" || k === "custom" || item.childrenSvg) {
1813
- const nodes = parseSvgFragment(item.childrenSvg);
1814
- if (nodes.length > 0) {
1815
- return /* @__PURE__ */ jsxRuntime.jsx(SvgNodeRenderer, { nodes });
1696
+ const next = {
1697
+ cellSize,
1698
+ aabbs,
1699
+ buckets
1700
+ };
1701
+ spatialIndexCache.set(items, next);
1702
+ return next;
1703
+ }
1704
+ function cellRangeForRect(r, cellSize) {
1705
+ const n = normalizeRect(r);
1706
+ const x1 = n.x + n.width;
1707
+ const y1 = n.y + n.height;
1708
+ const minIx = Math.floor(n.x / cellSize);
1709
+ const maxIx = Math.max(minIx, Math.ceil(x1 / cellSize) - 1);
1710
+ const minIy = Math.floor(n.y / cellSize);
1711
+ const maxIy = Math.max(minIy, Math.ceil(y1 / cellSize) - 1);
1712
+ return { minIx, maxIx, minIy, maxIy };
1713
+ }
1714
+ function cullItemsByViewportSpatial(items, visibleWorld, cellSize) {
1715
+ const { aabbs, buckets } = buildSpatialIndex(items, cellSize);
1716
+ const vr = cellRangeForRect(visibleWorld, cellSize);
1717
+ const seen = /* @__PURE__ */ new Set();
1718
+ const outIndices = [];
1719
+ for (let ix = vr.minIx; ix <= vr.maxIx; ix++) {
1720
+ for (let iy = vr.minIy; iy <= vr.maxIy; iy++) {
1721
+ const bucket = buckets.get(cellKey(ix, iy));
1722
+ if (!bucket) {
1723
+ continue;
1724
+ }
1725
+ for (const index of bucket) {
1726
+ if (seen.has(index)) {
1727
+ continue;
1728
+ }
1729
+ seen.add(index);
1730
+ const aabb = aabbs[index];
1731
+ if (aabb && rectsIntersect(aabb, visibleWorld)) {
1732
+ outIndices.push(index);
1733
+ }
1734
+ }
1816
1735
  }
1817
1736
  }
1818
- return null;
1737
+ outIndices.sort((a, b) => a - b);
1738
+ return outIndices.map((index) => items[index]).filter((item) => item != null);
1739
+ }
1740
+
1741
+ // src/scene/cull.ts
1742
+ var SPATIAL_MIN_ITEMS = 400;
1743
+ var SPATIAL_CELL_SIZE = 256;
1744
+ function cullItemsLinear(items, visibleWorld) {
1745
+ return items.filter(
1746
+ (item) => rectsIntersect(boundsAabbForCull(item), visibleWorld)
1747
+ );
1748
+ }
1749
+ function boundsAabbForCull(item) {
1750
+ return boundsAabbForRotatedItem(item);
1751
+ }
1752
+ function cullItemsByViewport(items, visibleWorld) {
1753
+ if (items.length < SPATIAL_MIN_ITEMS) {
1754
+ return cullItemsLinear(items, visibleWorld);
1755
+ }
1756
+ return cullItemsByViewportSpatial(items, visibleWorld, SPATIAL_CELL_SIZE);
1819
1757
  }
1820
1758
  var MemoShape = react.memo(function MemoShape2({
1821
1759
  item
@@ -1990,16 +1928,14 @@ function itemHitTestWorldPoint(item, worldX, worldY, options) {
1990
1928
  return true;
1991
1929
  }
1992
1930
  }
1931
+ return false;
1993
1932
  }
1994
- if (pts?.length === 1) {
1933
+ if (pts && pts.length === 1) {
1995
1934
  const p = pts[0];
1996
- if (p) {
1997
- const cw = itemLocalToWorld(p.x, p.y, item.x, item.y, w, h, rot);
1998
- const dsq = (worldX - cw.x) ** 2 + (worldY - cw.y) ** 2;
1999
- if (dsq <= tolSq) {
2000
- return true;
2001
- }
2002
- }
1935
+ if (!p) return false;
1936
+ const cw = itemLocalToWorld(p.x, p.y, item.x, item.y, w, h, rot);
1937
+ const dsq = (worldX - cw.x) ** 2 + (worldY - cw.y) ** 2;
1938
+ return dsq <= tolSq;
2003
1939
  }
2004
1940
  return hitTestFilledShape(item, worldX, worldY);
2005
1941
  }
@@ -2118,6 +2054,11 @@ var NativeVectorViewport = react.forwardRef(function NativeVectorViewport2({
2118
2054
  () => items.filter((it) => selectedIds.includes(it.id)),
2119
2055
  [items, selectedIds]
2120
2056
  );
2057
+ const sceneItems = react.useMemo(() => {
2058
+ if (eraserPreviewIds.length === 0) return items;
2059
+ const hidden = new Set(eraserPreviewIds);
2060
+ return items.filter((it) => !hidden.has(it.id));
2061
+ }, [items, eraserPreviewIds]);
2121
2062
  const showResizeHandles = interactive && selectedItems.length === 1 && !selectedItems[0]?.locked;
2122
2063
  const lastPinchDist = react.useRef(null);
2123
2064
  const lastPanPoint = react.useRef(null);
@@ -2443,7 +2384,7 @@ var NativeVectorViewport = react.forwardRef(function NativeVectorViewport2({
2443
2384
  /* @__PURE__ */ jsxRuntime.jsx(
2444
2385
  NativeSceneRenderer,
2445
2386
  {
2446
- items,
2387
+ items: sceneItems,
2447
2388
  camera,
2448
2389
  width: size.width,
2449
2390
  height: size.height