Bin
2025-12-16 9e0b2ba2c317b1a86212f24cbae3195ad1f3dbfa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
import type React from "react";
import { Shape } from "react-konva";
import type { BezierPoint } from "../types";
import { GHOST_LINE_STYLING, DEFAULT_STROKE_COLOR, HIT_RADIUS } from "../constants";
import { findClosestPointOnPath, getDistance } from "../eventHandlers/utils";
 
interface GhostLineProps {
  initialPoints: BezierPoint[];
  cursorPositionRef: React.RefObject<{ x: number; y: number } | null>;
  draggedControlPoint: { pointIndex: number; controlIndex: number } | null;
  draggedPointIndex?: number | null;
  isDraggingNewBezier?: boolean;
  isPathClosed: boolean;
  allowClose: boolean;
  transform: { zoom: number; offsetX: number; offsetY: number };
  fitScale: number;
  maxPoints?: number;
  minPoints?: number; // Add minPoints prop
  skeletonEnabled?: boolean;
  selectedPointIndex?: number | null;
  lastAddedPointId?: string | null;
  activePointId?: string | null;
  stroke?: string;
  pixelSnapping?: boolean;
  drawingDisabled?: boolean;
  // Non-hover-related disabled states (for real-time checks)
  isShiftKeyHeld?: boolean;
  transformMode?: boolean;
  effectiveSelectedPointsSize?: number;
}
 
export const GhostLine: React.FC<GhostLineProps> = ({
  initialPoints,
  cursorPositionRef,
  draggedControlPoint,
  draggedPointIndex = null,
  isDraggingNewBezier = false,
  isPathClosed,
  allowClose,
  transform,
  fitScale,
  maxPoints,
  minPoints,
  skeletonEnabled,
  selectedPointIndex = null,
  lastAddedPointId,
  activePointId = null,
  stroke = DEFAULT_STROKE_COLOR,
  pixelSnapping = false,
  drawingDisabled = false,
  isShiftKeyHeld = false,
  transformMode = false,
  effectiveSelectedPointsSize = 0,
}) => {
  // Helper function to snap coordinates to pixel grid
  const snapToPixel = (point: { x: number; y: number }) => {
    if (!pixelSnapping) return point;
    return {
      x: Math.round(point.x),
      y: Math.round(point.y),
    };
  };
  // Get the active point for the ghost line
  const getActivePoint = () => {
    // In skeleton mode, use the active point (selected point)
    if (skeletonEnabled && activePointId) {
      const activePoint = initialPoints.find((p) => p.id === activePointId);
      if (activePoint) {
        return activePoint;
      }
    }
 
    // If a point is selected, use that point for the ghost line
    if (
      selectedPointIndex !== null &&
      selectedPointIndex !== undefined &&
      selectedPointIndex >= 0 &&
      selectedPointIndex < initialPoints.length
    ) {
      return initialPoints[selectedPointIndex];
    }
 
    // In non-skeleton mode, use the last added point
    // Fallback to lastAddedPointId for backward compatibility
    if (lastAddedPointId) {
      const lastAddedPoint = initialPoints.find((p) => p.id === lastAddedPointId);
      if (lastAddedPoint) {
        return lastAddedPoint;
      }
    }
 
    // Final fallback: use the last point in the array
    if (initialPoints.length > 0) {
      const lastPoint = initialPoints[initialPoints.length - 1];
      return lastPoint;
    }
    return null;
  };
 
  const activePoint = getActivePoint();
 
  // Always render the Shape components - conditional logic happens inside sceneFunc
  // This allows the ghost line to update via stage.batchDraw() without React re-renders
  return (
    <>
      {/* Ghost line from active point to cursor */}
      {/* Only show ghost line when not at max points */}
      {activePoint && (
        <Shape
          stroke={stroke}
          strokeWidth={GHOST_LINE_STYLING.STROKE_WIDTH}
          strokeScaleEnabled={false}
          lineCap="round"
          lineJoin="round"
          dash={GHOST_LINE_STYLING.DASH}
          opacity={GHOST_LINE_STYLING.OPACITY}
          sceneFunc={(ctx, shape) => {
            // Read cursor position from ref inside sceneFunc for real-time updates
            const cursorPos = cursorPositionRef.current;
 
            // Check all conditions for showing ghost line
            // Show ghost line when we have points and cursor position, unless:
            // - We're dragging something
            // - Path is closed
            // - Max points reached
            // - Drawing is disabled (includes hovering over points, control points, or segments)
            if (
              !cursorPos ||
              draggedControlPoint ||
              draggedPointIndex !== null ||
              isDraggingNewBezier ||
              isPathClosed ||
              (maxPoints !== undefined && initialPoints.length >= maxPoints)
            ) {
              return; // Don't draw anything
            }
 
            // Real-time hover detection: check if cursor is over points, control points, or segments
            // This runs inside sceneFunc for real-time updates without React re-renders
            const scale = transform.zoom * fitScale;
 
            // Check if hovering over control points
            if (cursorPos && initialPoints.length > 0) {
              const controlPointHitRadius = HIT_RADIUS.CONTROL_POINT / scale;
              for (let i = 0; i < initialPoints.length; i++) {
                const point = initialPoints[i];
                if (point.isBezier) {
                  if (point.controlPoint1) {
                    const distance = Math.sqrt(
                      (cursorPos.x - point.controlPoint1.x) ** 2 + (cursorPos.y - point.controlPoint1.y) ** 2,
                    );
                    if (distance <= controlPointHitRadius) {
                      return; // Hide ghost line when hovering over control points
                    }
                  }
                  if (point.controlPoint2) {
                    const distance = Math.sqrt(
                      (cursorPos.x - point.controlPoint2.x) ** 2 + (cursorPos.y - point.controlPoint2.y) ** 2,
                    );
                    if (distance <= controlPointHitRadius) {
                      return; // Hide ghost line when hovering over control points
                    }
                  }
                }
              }
            }
 
            // Check if hovering over points (except last point and first point when closing is possible)
            if (cursorPos && initialPoints.length > 0) {
              const selectionHitRadius = HIT_RADIUS.SELECTION / scale;
              for (let i = 0; i < initialPoints.length; i++) {
                const point = initialPoints[i];
                const distance = Math.sqrt((cursorPos.x - point.x) ** 2 + (cursorPos.y - point.y) ** 2);
                if (distance <= selectionHitRadius) {
                  // Allow ghost line when hovering over the last point (so you can continue drawing)
                  if (i === initialPoints.length - 1) {
                    continue; // Don't hide ghost line for the last point
                  }
                  // Allow ghost line when hovering over the first point if path closing is possible
                  if (i === 0 && allowClose && !isPathClosed) {
                    continue; // Don't hide ghost line for the first point when closing is possible
                  }
                  // Hide ghost line when hovering over other points
                  return;
                }
              }
            }
 
            // Check if hovering over path segments
            if (cursorPos && initialPoints.length >= 2) {
              const segmentHitRadius = HIT_RADIUS.SEGMENT / scale;
              const closestPathPoint = findClosestPointOnPath(cursorPos, initialPoints, allowClose, isPathClosed);
              if (closestPathPoint && getDistance(cursorPos, closestPathPoint.point) <= segmentHitRadius) {
                return; // Hide ghost line when hovering over segments
              }
            }
 
            // Hide ghost line when drawing is disabled for non-hover reasons (Shift key, transform mode, etc.)
            // Note: Hover detection (points, control points, segments) is handled above in real-time
            // Check non-hover-related disabled states directly (not from drawingDisabled which includes hover state)
            // Only show ghost line if drawing is enabled OR if we have a selected point (for editing)
            const isNonHoverDisabled = isShiftKeyHeld || transformMode || effectiveSelectedPointsSize > 1;
            if (isNonHoverDisabled) {
              // If we have a selected point, still show ghost line (useful for editing)
              // Otherwise, hide it when drawing is disabled (but only for non-hover reasons)
              // Hover detection is already handled above, so this only checks Shift key, transform mode, etc.
              if (selectedPointIndex === null) {
                return; // Don't draw anything - drawing is disabled and no point is selected
              }
            }
 
            // Check if we should hide ghost line when closing indicator is visible
            const closingTargetCheck = (() => {
              if (!allowClose || !activePoint) return null;
 
              const canClosePath = initialPoints.length > 2 || initialPoints.some((p) => p.isBezier);
              if (!canClosePath || (minPoints && initialPoints.length < minPoints)) return null;
 
              const firstPoint = initialPoints[0];
              const lastPoint = initialPoints[initialPoints.length - 1];
              const closeRadius = GHOST_LINE_STYLING.CLOSE_RADIUS / (transform.zoom * fitScale);
 
              const isActivePointFirst = activePoint.id === firstPoint.id;
              const isActivePointLast = activePoint.id === lastPoint.id;
 
              if (!isActivePointFirst && !isActivePointLast) return null;
 
              const distanceToFirst = Math.sqrt((cursorPos.x - firstPoint.x) ** 2 + (cursorPos.y - firstPoint.y) ** 2);
              const distanceToLast = Math.sqrt((cursorPos.x - lastPoint.x) ** 2 + (cursorPos.y - lastPoint.y) ** 2);
 
              if (isActivePointFirst && distanceToLast <= closeRadius) {
                return { point: lastPoint, index: initialPoints.length - 1 };
              }
              if (isActivePointLast && distanceToFirst <= closeRadius) {
                return { point: firstPoint, index: 0 };
              }
              return null;
            })();
 
            if (closingTargetCheck) return; // Hide ghost line when closing indicator should show
 
            ctx.beginPath();
            ctx.moveTo(activePoint.x, activePoint.y);
 
            // Snap cursor position to pixel grid if enabled
            const snappedCursor = snapToPixel(cursorPos);
 
            // Check if the active point is a bezier point and has control points
            if (activePoint.isBezier && activePoint.controlPoint1 && activePoint.controlPoint2) {
              // Calculate control points for the ghost curve
              // Use the same logic as the path rendering for partial bezier curves
              const dx = snappedCursor.x - activePoint.x;
              const dy = snappedCursor.y - activePoint.y;
              const controlX = snappedCursor.x - dx * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER;
              const controlY = snappedCursor.y - dy * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER;
 
              // Draw bezier curve using the active point's controlPoint2 and calculated control point
              // controlPoint2 is the "outgoing" control point that affects the curve direction
              ctx.bezierCurveTo(
                activePoint.controlPoint2.x,
                activePoint.controlPoint2.y,
                controlX,
                controlY,
                snappedCursor.x,
                snappedCursor.y,
              );
            } else {
              // Straight line
              ctx.lineTo(snappedCursor.x, snappedCursor.y);
            }
 
            ctx.strokeShape(shape);
          }}
        />
      )}
 
      {/* Closing indicator when near first or last point - always show when appropriate */}
      {activePoint && (
        <Shape
          stroke={GHOST_LINE_STYLING.CLOSING_INDICATOR_STROKE}
          strokeWidth={GHOST_LINE_STYLING.CLOSING_INDICATOR_STROKE_WIDTH}
          strokeScaleEnabled={false}
          lineCap="round"
          lineJoin="round"
          dash={GHOST_LINE_STYLING.CLOSING_INDICATOR_DASH}
          opacity={GHOST_LINE_STYLING.CLOSING_INDICATOR_OPACITY}
          sceneFunc={(ctx, shape) => {
            // Read cursor position and check for closing target inside sceneFunc
            const cursorPos = cursorPositionRef.current;
            if (!cursorPos || isPathClosed || (drawingDisabled && !allowClose)) return;
 
            // Calculate closing target
            const closingTarget = (() => {
              if (!allowClose) return null;
 
              const canClosePath = initialPoints.length > 2 || initialPoints.some((p) => p.isBezier);
              if (!canClosePath || (minPoints && initialPoints.length < minPoints)) return null;
 
              const firstPoint = initialPoints[0];
              const lastPoint = initialPoints[initialPoints.length - 1];
              const closeRadius = GHOST_LINE_STYLING.CLOSE_RADIUS / (transform.zoom * fitScale);
 
              const isActivePointFirst = activePoint.id === firstPoint.id;
              const isActivePointLast = activePoint.id === lastPoint.id;
 
              if (!isActivePointFirst && !isActivePointLast) return null;
 
              const distanceToFirst = Math.sqrt((cursorPos.x - firstPoint.x) ** 2 + (cursorPos.y - firstPoint.y) ** 2);
              const distanceToLast = Math.sqrt((cursorPos.x - lastPoint.x) ** 2 + (cursorPos.y - lastPoint.y) ** 2);
 
              if (isActivePointFirst && distanceToLast <= closeRadius) {
                return { point: lastPoint, index: initialPoints.length - 1 };
              }
              if (isActivePointLast && distanceToFirst <= closeRadius) {
                return { point: firstPoint, index: 0 };
              }
              return null;
            })();
 
            if (!closingTarget) return;
 
            ctx.beginPath();
            ctx.moveTo(activePoint.x, activePoint.y);
 
            const targetPoint = closingTarget.point;
 
            // Check if either point is a bezier point and handle curves accordingly
            if (
              activePoint.isBezier &&
              activePoint.controlPoint2 &&
              targetPoint.isBezier &&
              targetPoint.controlPoint1
            ) {
              // Both points are bezier - use their control points
              ctx.bezierCurveTo(
                activePoint.controlPoint2.x,
                activePoint.controlPoint2.y,
                targetPoint.controlPoint1.x,
                targetPoint.controlPoint1.y,
                targetPoint.x,
                targetPoint.y,
              );
            } else if (activePoint.isBezier && activePoint.controlPoint2) {
              // Only active point is bezier - calculate control point for target point
              const dx = targetPoint.x - activePoint.x;
              const dy = targetPoint.y - activePoint.y;
              const controlX = targetPoint.x - dx * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER;
              const controlY = targetPoint.y - dy * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER;
              ctx.bezierCurveTo(
                activePoint.controlPoint2.x,
                activePoint.controlPoint2.y,
                controlX,
                controlY,
                targetPoint.x,
                targetPoint.y,
              );
            } else if (targetPoint.isBezier && targetPoint.controlPoint1) {
              // Only target point is bezier - calculate control point for active point
              const dx = targetPoint.x - activePoint.x;
              const dy = targetPoint.y - activePoint.y;
              const controlX = activePoint.x + dx * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER;
              const controlY = activePoint.y + dy * GHOST_LINE_STYLING.BEZIER_CONTROL_MULTIPLIER;
              ctx.bezierCurveTo(
                controlX,
                controlY,
                targetPoint.controlPoint1.x,
                targetPoint.controlPoint1.y,
                targetPoint.x,
                targetPoint.y,
              );
            } else {
              // Both points are regular - straight line
              ctx.lineTo(targetPoint.x, targetPoint.y);
            }
 
            ctx.strokeShape(shape);
          }}
        />
      )}
    </>
  );
};