@bycrux/editor 0.11.0 → 0.11.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Centre/middle snap for the caption move gesture.
3
+ *
4
+ * Regression: the first implementation of `captionDragGeometry` translated the
5
+ * segment with no snapping at all, so captions were the only draggable thing in
6
+ * the preview that would not settle on the frame's centre line — overlays did,
7
+ * captions did not. Edge snap remains deliberately absent (its geometry does
8
+ * not transfer to a content-sized anchor box); only centre/middle is expected.
9
+ */
10
+ import { describe, it, expect } from 'vitest'
11
+ import {
12
+ captionDragGeometry,
13
+ captionDragPatch,
14
+ CAPTION_SNAP_THRESHOLD,
15
+ type CaptionDragState,
16
+ type CaptionFrameMetrics,
17
+ } from '../preview/captionDragState'
18
+
19
+ // previewScale 1 with a 1080x1920 design frame: 1 screen px == 1 design px, so
20
+ // a dx of 10.8px is exactly 1% of frame width.
21
+ const METRICS: CaptionFrameMetrics = { previewScale: 1, renderW: 1080, renderH: 1920 }
22
+ const PX_PER_PCT_X = 1080 / 100
23
+ const PX_PER_PCT_Y = 1920 / 100
24
+
25
+ function move(initOffsetX: number, initOffsetY: number): CaptionDragState {
26
+ return {
27
+ id: 'cap-1', type: 'move',
28
+ initX: 0, initY: 0,
29
+ initOffsetX, initOffsetY, initScale: 1,
30
+ }
31
+ }
32
+
33
+ describe('captionDragGeometry — centre/middle snap', () => {
34
+ it('snaps offsetX to exactly 0 inside the threshold', () => {
35
+ // Start 5% left of centre, drag right to land 1% past centre (inside 2.5%).
36
+ const g = captionDragGeometry(move(-5, 40), 6 * PX_PER_PCT_X, 0, METRICS)
37
+ expect(g.offsetX).toBe(0)
38
+ expect(g.snapX).toBe(true)
39
+ })
40
+
41
+ it('snaps offsetY to exactly 0 inside the threshold', () => {
42
+ const g = captionDragGeometry(move(40, -5), 0, 6 * PX_PER_PCT_Y, METRICS)
43
+ expect(g.offsetY).toBe(0)
44
+ expect(g.snapY).toBe(true)
45
+ })
46
+
47
+ it('snaps both axes at once when dragged through the centre point', () => {
48
+ const g = captionDragGeometry(move(-1, -1), PX_PER_PCT_X, PX_PER_PCT_Y, METRICS)
49
+ expect(g).toMatchObject({ offsetX: 0, offsetY: 0, snapX: true, snapY: true })
50
+ })
51
+
52
+ it('does NOT snap outside the threshold — the raw offset is preserved', () => {
53
+ // Land 4% right of centre: outside 2.5%, so no snap and no rounding.
54
+ const g = captionDragGeometry(move(0, 40), 4 * PX_PER_PCT_X, 0, METRICS)
55
+ expect(g.offsetX).toBeCloseTo(4, 6)
56
+ expect(g.snapX).toBe(false)
57
+ })
58
+
59
+ it('uses the same threshold as the overlay hook', () => {
60
+ // Just inside snaps, just outside does not — pinning the boundary so the
61
+ // two gestures cannot drift apart.
62
+ const inside = captionDragGeometry(move(0, 40), (CAPTION_SNAP_THRESHOLD - 0.1) * PX_PER_PCT_X, 0, METRICS)
63
+ const outside = captionDragGeometry(move(0, 40), (CAPTION_SNAP_THRESHOLD + 0.1) * PX_PER_PCT_X, 0, METRICS)
64
+ expect(inside.snapX).toBe(true)
65
+ expect(outside.snapX).toBe(false)
66
+ })
67
+
68
+ it('commits the snapped value, not the raw pointer position', () => {
69
+ const d = move(-5, 40)
70
+ const g = captionDragGeometry(d, 6 * PX_PER_PCT_X, 0, METRICS)
71
+ expect(captionDragPatch(d, g)).toEqual({ offsetX: 0, offsetY: g.offsetY })
72
+ })
73
+
74
+ it('leaves resize gestures unsnapped and untouched', () => {
75
+ const d: CaptionDragState = {
76
+ id: 'cap-1', type: 'resize-se',
77
+ initX: 0, initY: 0, initOffsetX: 1, initOffsetY: 1, initScale: 1,
78
+ }
79
+ const g = captionDragGeometry(d, 20, 20, METRICS)
80
+ // A resize must never move the segment, and must not report a snap.
81
+ expect(g.offsetX).toBe(1)
82
+ expect(g.offsetY).toBe(1)
83
+ expect(g.snapX).toBeUndefined()
84
+ expect(g.snapY).toBeUndefined()
85
+ expect(captionDragPatch(d, g)).toEqual({ scale: g.scale })
86
+ })
87
+ })
@@ -345,6 +345,18 @@ export default function CaptionPreview({
345
345
  )}
346
346
  </OverlayErrorBoundary>
347
347
 
348
+ {/* Centre/middle snap guides. Same amber hairlines, same z 50, and the
349
+ same `drag.type === 'move'` gate the overlay layer uses, so a snapped
350
+ caption reads identically to a snapped overlay. Only the centre pair
351
+ exists here — caption edge snap is deliberately not implemented (its
352
+ geometry does not transfer; see captionDragState.ts). */}
353
+ {drag?.type === 'move' && live?.snapX && (
354
+ <div className="absolute top-0 bottom-0 left-1/2 w-px bg-amber-400 pointer-events-none" style={{ zIndex: 50 }} />
355
+ )}
356
+ {drag?.type === 'move' && live?.snapY && (
357
+ <div className="absolute left-0 right-0 top-1/2 h-px bg-amber-400 pointer-events-none" style={{ zIndex: 50 }} />
358
+ )}
359
+
348
360
  {/* Selection box — the only interactive part of the caption layer. z 50
349
361
  matches the overlay selection handles (see the layering note above);
350
362
  the wrapper stays click-through so only the box itself catches events. */}
@@ -93,9 +93,14 @@ describe('captionDragGeometry — move', () => {
93
93
  })
94
94
 
95
95
  it('is a no-op at the starting pointer position', () => {
96
+ // 12/34 are both well outside the centre-snap radius, so the geometry is
97
+ // the untouched starting offsets. The move branch also reports whether each
98
+ // axis is currently snapped (see captionSnap.test.ts) — asserted here as
99
+ // false so this stays a genuine no-op check rather than an assertion about
100
+ // the return shape.
96
101
  const d = drag({ initOffsetX: 12, initOffsetY: 34, initScale: 1.5 })
97
102
  expect(captionDragGeometry(d, d.initX, d.initY, metrics(540)))
98
- .toEqual({ offsetX: 12, offsetY: 34, scale: 1.5 })
103
+ .toEqual({ offsetX: 12, offsetY: 34, scale: 1.5, snapX: false, snapY: false })
99
104
  })
100
105
  })
101
106
 
@@ -60,8 +60,18 @@ export interface CaptionGeometry {
60
60
  offsetX: number
61
61
  offsetY: number
62
62
  scale: number
63
+ /** True while the move gesture is snapped to the frame's horizontal centre. */
64
+ snapX?: boolean
65
+ /** True while the move gesture is snapped to the frame's vertical middle. */
66
+ snapY?: boolean
63
67
  }
64
68
 
69
+ /**
70
+ * Centre/middle snap radius, in percent of frame. Matches `SNAP_THRESHOLD` in
71
+ * `useDragOverlay` so dragging a caption and dragging an overlay feel the same.
72
+ */
73
+ export const CAPTION_SNAP_THRESHOLD = 2.5
74
+
65
75
  /**
66
76
  * The preview's design-resolution mapping.
67
77
  *
@@ -145,7 +155,23 @@ export function captionDragGeometry(
145
155
  const { dx, dy } = screenDeltaToFramePercent(clientX - drag.initX, clientY - drag.initY, metrics)
146
156
 
147
157
  if (drag.type === 'move') {
148
- return { offsetX: drag.initOffsetX + dx, offsetY: drag.initOffsetY + dy, scale: drag.initScale }
158
+ // Centre/middle snap. Unlike the overlay hook's EDGE snap — whose
159
+ // `(0.5 - s/2) * 100` geometry genuinely does not transfer to a caption's
160
+ // content-sized anchor box (see the file header) — the centre snap is
161
+ // geometry-independent: offsetX 0 is the frame's horizontal centre and
162
+ // offsetY 0 its vertical middle, for a caption exactly as for an overlay.
163
+ // Same threshold as useDragOverlay so both gestures feel identical.
164
+ const rawX = drag.initOffsetX + dx
165
+ const rawY = drag.initOffsetY + dy
166
+ const snapX = Math.abs(rawX) < CAPTION_SNAP_THRESHOLD
167
+ const snapY = Math.abs(rawY) < CAPTION_SNAP_THRESHOLD
168
+ return {
169
+ offsetX: snapX ? 0 : rawX,
170
+ offsetY: snapY ? 0 : rawY,
171
+ scale: drag.initScale,
172
+ snapX,
173
+ snapY,
174
+ }
149
175
  }
150
176
 
151
177
  // Resize from a corner: project the pointer delta onto the corner's outward