@bycrux/editor 0.8.1 → 0.8.3

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.8.1",
3
+ "version": "0.8.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -122,6 +122,69 @@ interface Props {
122
122
  hiddenElementIds?: string[]
123
123
  }
124
124
 
125
+ // Plain default for the inline editor when we can't read the rendered text's
126
+ // real style (e.g. no matching node found).
127
+ const FALLBACK_EDIT_STYLE: Partial<CSSStyleDeclaration> = {
128
+ color: '#ffffff',
129
+ fontSize: '18px',
130
+ fontFamily: 'system-ui, sans-serif',
131
+ whiteSpace: 'pre-wrap',
132
+ wordBreak: 'break-word',
133
+ }
134
+
135
+ // Find the innermost element under `root` whose full text equals `text` — the
136
+ // overlay's text container. Used to copy its computed style onto the editor.
137
+ function findTextNode(root: HTMLElement, text: string): HTMLElement | null {
138
+ const want = text.trim()
139
+ if (!want) return null
140
+ const matches: HTMLElement[] = []
141
+ const walk = (el: HTMLElement) => {
142
+ if ((el.textContent ?? '').trim() === want) matches.push(el)
143
+ for (const child of Array.from(el.children)) {
144
+ if (child instanceof HTMLElement) walk(child)
145
+ }
146
+ }
147
+ walk(root)
148
+ if (matches.length === 0) return null
149
+ // Innermost match = the one with the fewest descendant elements.
150
+ return matches.reduce((best, el) =>
151
+ el.querySelectorAll('*').length < best.querySelectorAll('*').length ? el : best,
152
+ )
153
+ }
154
+
155
+ // Snapshot the rendered overlay text's computed style so the inline editor
156
+ // matches it instead of a generic stub. Overlays are authored at native slide
157
+ // pixels then CSS-scaled by `scale`; the editor is positioned in already-scaled
158
+ // display coords with no transform, so px metrics must be multiplied by `scale`.
159
+ // Returns null when no text node is found (caller falls back).
160
+ function captureTextStyle(
161
+ wrapper: HTMLElement | undefined,
162
+ text: string,
163
+ scale: number,
164
+ ): Partial<CSSStyleDeclaration> | null {
165
+ if (!wrapper || typeof window === 'undefined') return null
166
+ const target = findTextNode(wrapper, text)
167
+ if (!target) return null
168
+ const cs = window.getComputedStyle(target)
169
+ const scalePx = (v: string) => {
170
+ const n = parseFloat(v)
171
+ return Number.isFinite(n) ? `${n * scale}px` : v
172
+ }
173
+ return {
174
+ color: cs.color,
175
+ fontFamily: cs.fontFamily,
176
+ fontWeight: cs.fontWeight,
177
+ fontStyle: cs.fontStyle,
178
+ textAlign: cs.textAlign,
179
+ textTransform: cs.textTransform,
180
+ fontSize: scalePx(cs.fontSize),
181
+ lineHeight: cs.lineHeight === 'normal' ? 'normal' : scalePx(cs.lineHeight),
182
+ letterSpacing: cs.letterSpacing === 'normal' ? 'normal' : scalePx(cs.letterSpacing),
183
+ whiteSpace: 'pre-wrap',
184
+ wordBreak: 'break-word',
185
+ } as Partial<CSSStyleDeclaration>
186
+ }
187
+
125
188
  export default function SlideCanvas({
126
189
  slide,
127
190
  slideId,
@@ -158,6 +221,7 @@ export default function SlideCanvas({
158
221
  // Inline text edit state.
159
222
  const [editingId, setEditingId] = useState<string | null>(null)
160
223
  const [editRect, setEditRect] = useState<{ left: number; top: number; width: number; height: number } | null>(null)
224
+ const [editStyle, setEditStyle] = useState<Partial<CSSStyleDeclaration> | null>(null)
161
225
 
162
226
  // Crop mode local state — source fraction window + loaded natural dims.
163
227
  const [cropState, setCropState] = useState<CropMode>(null)
@@ -266,6 +330,10 @@ export default function SlideCanvas({
266
330
  // ── Inline text edit ──
267
331
  function beginTextEdit(element: OverlayElement) {
268
332
  if (!interactive || typeof element.overlay.props.text !== 'string') return
333
+ // Snapshot the live text's style BEFORE we hide it / re-render.
334
+ setEditStyle(
335
+ captureTextStyle(wrapperRefs.current.get(element.id), element.overlay.props.text, scale),
336
+ )
269
337
  setEditRect({
270
338
  left: element.x * scale,
271
339
  top: element.y * scale,
@@ -275,10 +343,15 @@ export default function SlideCanvas({
275
343
  setEditingId(element.id)
276
344
  }
277
345
 
278
- function commitTextEdit(element: OverlayElement, value: string) {
279
- void updateOverlayProp?.(sid, element.id, 'text', value)
346
+ function endTextEdit() {
280
347
  setEditingId(null)
281
348
  setEditRect(null)
349
+ setEditStyle(null)
350
+ }
351
+
352
+ function commitTextEdit(element: OverlayElement, value: string) {
353
+ void updateOverlayProp?.(sid, element.id, 'text', value)
354
+ endTextEdit()
282
355
  }
283
356
 
284
357
  // ── Pointer wiring shared by drag/resize/rotate ──
@@ -461,6 +534,9 @@ export default function SlideCanvas({
461
534
  height: element.h,
462
535
  transform: `scale(${scale})`,
463
536
  transformOrigin: 'top left',
537
+ // Hide the live text while it's being edited in place so the
538
+ // InlineTextEditor isn't doubled over it.
539
+ visibility: editingId === element.id ? 'hidden' : undefined,
464
540
  }}
465
541
  >
466
542
  <OverlayErrorBoundary
@@ -637,16 +713,10 @@ export default function SlideCanvas({
637
713
  key={editingId}
638
714
  initialValue={initial}
639
715
  rect={editRect}
640
- styleSnapshot={{
641
- color: '#ffffff',
642
- fontSize: '18px',
643
- fontFamily: 'system-ui, sans-serif',
644
- whiteSpace: 'pre-wrap',
645
- wordBreak: 'break-word',
646
- }}
716
+ styleSnapshot={editStyle ?? FALLBACK_EDIT_STYLE}
647
717
  onChange={() => {}}
648
718
  onCommit={(value) => commitTextEdit(el, value)}
649
- onCancel={() => { setEditingId(null); setEditRect(null) }}
719
+ onCancel={endTextEdit}
650
720
  />
651
721
  )
652
722
  })()}
package/src/schema.ts CHANGED
@@ -77,6 +77,8 @@ export interface VisualItem {
77
77
  nobg_src?: string // video type only — ProRes 4444 .mov for final render
78
78
  nobg_preview_src?: string // video type only — VP9 WebM with alpha for browser preview
79
79
  normalizedSrc?: string // derived per-window normalized cache; render/preview prefer it; src stays original
80
+ /** Source-time (original coords) the normalizedSrc cache starts at; the cache covers [normalizedInPoint, normalizedInPoint + duration]. Absent ⇒ assume it starts at the clip's inPoint (legacy rebase-to-0). */
81
+ normalizedInPoint?: number
80
82
  muted?: boolean // video type only — suppress audio in preview and render
81
83
  sourceCrop?: { x: number; y: number; w: number; h: number } // video type only — non-destructive crop of the source clip (0–1 fractions)
82
84
  sourceWidth?: number // video type only — intrinsic width of the source clip in pixels
@@ -8,7 +8,8 @@ import { effectiveInPoint, effectiveOutPoint } from '../useVideoPlayback'
8
8
  // and must NOT rebase.
9
9
 
10
10
  describe('effectiveInPoint', () => {
11
- it('rebases to 0 when normalizedSrc is the chosen src', () => {
11
+ it('rebases to 0 when normalizedSrc is the chosen src (legacy: no normalizedInPoint)', () => {
12
+ // Legacy: no normalizedInPoint → origin defaults to inPoint → effectiveInPoint = 0
12
13
  expect(effectiveInPoint({ inPoint: 496.92, normalizedSrc: '/cache/window.mp4' })).toBe(0)
13
14
  })
14
15
 
@@ -26,10 +27,25 @@ describe('effectiveInPoint', () => {
26
27
  it('defaults to 0 when inPoint is absent and no cache', () => {
27
28
  expect(effectiveInPoint({ src: '/orig.mov' })).toBe(0)
28
29
  })
30
+
31
+ // Regression: trim-after-cache — cache origin 0, inPoint trimmed to 0.9157
32
+ it('rebases by normalizedInPoint=0 after a start-trim (cache origin 0, inPoint 0.9157)', () => {
33
+ // Cache was built at origin 0. User trimmed the start to 0.9157.
34
+ // effectiveInPoint should be 0.9157 - 0 = 0.9157, NOT 0.
35
+ expect(
36
+ effectiveInPoint({ inPoint: 0.9157, normalizedInPoint: 0, normalizedSrc: '/cache/window.mp4' }),
37
+ ).toBeCloseTo(0.9157, 5)
38
+ })
39
+
40
+ it('rebases by a non-zero normalizedInPoint (cache origin 5, inPoint 6)', () => {
41
+ expect(
42
+ effectiveInPoint({ inPoint: 6, normalizedInPoint: 5, normalizedSrc: '/cache/window.mp4' }),
43
+ ).toBeCloseTo(1, 5)
44
+ })
29
45
  })
30
46
 
31
47
  describe('effectiveOutPoint', () => {
32
- it('rebases to the window length (outPoint - inPoint) for a normalizedSrc cache', () => {
48
+ it('rebases to the window length (outPoint - inPoint) for a normalizedSrc cache (legacy: no normalizedInPoint)', () => {
33
49
  // original inPoint 496.92, outPoint 514.92 → 18s window cache
34
50
  expect(
35
51
  effectiveOutPoint({ inPoint: 496.92, outPoint: 514.92, normalizedSrc: '/cache/window.mp4' }),
@@ -49,4 +65,19 @@ describe('effectiveOutPoint', () => {
49
65
  it('returns undefined when no outPoint is stored', () => {
50
66
  expect(effectiveOutPoint({ inPoint: 496.92, normalizedSrc: '/cache/window.mp4' })).toBeUndefined()
51
67
  })
68
+
69
+ // Regression: trim-after-cache — cache origin 0, inPoint 0.9157, outPoint 16.97
70
+ it('rebases outPoint by normalizedInPoint=0 after a start-trim', () => {
71
+ // Cache was built at origin 0. User trimmed start to 0.9157.
72
+ // effectiveOutPoint = 16.97 - 0 = 16.97, NOT 16.97 - 0.9157.
73
+ expect(
74
+ effectiveOutPoint({ inPoint: 0.9157, outPoint: 16.97, normalizedInPoint: 0, normalizedSrc: '/cache/window.mp4' }),
75
+ ).toBeCloseTo(16.97, 5)
76
+ })
77
+
78
+ it('rebases outPoint by a non-zero normalizedInPoint (cache origin 5, outPoint 20)', () => {
79
+ expect(
80
+ effectiveOutPoint({ inPoint: 6, outPoint: 20, normalizedInPoint: 5, normalizedSrc: '/cache/window.mp4' }),
81
+ ).toBeCloseTo(15, 5)
82
+ })
52
83
  })
@@ -32,41 +32,59 @@ function playbackSrcFor(clip: { src?: string; nobg_preview_src?: string; normali
32
32
 
33
33
  /**
34
34
  * The inPoint the preview should SEEK to for this clip, accounting for the
35
- * normalizedSrc cache rebase.
35
+ * normalizedSrc cache origin.
36
36
  *
37
- * A `normalizedSrc` cache covers exactly [inPoint, outPoint] of the original
38
- * and STARTS AT 0 (it is only `outPoint - inPoint` seconds long). When
39
- * `playbackSrcFor` chooses that cache as the playback src, seeking to the
40
- * original `clip.inPoint` (e.g. 496.92) would land far past the end of the
41
- * short file → the browser clamps to EOF and the preview freezes on the last
42
- * frame. So when the cache is the chosen src, the effective inPoint is 0 and
43
- * the window maps to [0, outPoint - inPoint].
37
+ * A `normalizedSrc` cache is a trimmed file that covers exactly
38
+ * [normalizedInPoint, normalizedInPoint + duration] of the original source and
39
+ * plays starting at time 0. When `playbackSrcFor` chooses the cache as the
40
+ * playback src, we must rebase by the cache origin so the seek position is
41
+ * relative to the cache's own timeline.
42
+ *
43
+ * The origin is `clip.normalizedInPoint ?? clip.inPoint ?? 0`:
44
+ * - When `normalizedInPoint` is set, the cache was built for a specific window
45
+ * that may differ from the current inPoint (e.g. after a start-trim): the
46
+ * cache still covers the new window, but we must subtract the origin so the
47
+ * seek lands at the right position inside the cache.
48
+ * - When `normalizedInPoint` is absent (legacy), the cache was built assuming
49
+ * it starts at the clip's inPoint → origin = inPoint → effectiveInPoint = 0
50
+ * (reproduces the old rebase-to-0 behavior).
44
51
  *
45
52
  * This mirrors render's `collectAllItems` (montaj_assets/render/render.js),
46
- * which substitutes `item.normalizedSrc` as the src AND rebases inPoint to 0.
53
+ * which rebases inPoint by the same origin.
47
54
  *
48
55
  * The rebase applies ONLY when the cache is actually the chosen src.
49
56
  * `nobg_preview_src` takes precedence in `playbackSrcFor` and is NOT a window
50
57
  * cache (it covers the full source), so it keeps the original inPoint — exactly
51
58
  * as render's nobg path does.
52
59
  */
53
- export function effectiveInPoint(clip: { inPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number {
60
+ export function effectiveInPoint(clip: { inPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number {
54
61
  const usingNormalizedCache = !clip.nobg_preview_src && !!clip.normalizedSrc
55
- return usingNormalizedCache ? 0 : (clip.inPoint ?? 0)
62
+ if (!usingNormalizedCache) return clip.inPoint ?? 0
63
+ const origin = clip.normalizedInPoint ?? clip.inPoint ?? 0
64
+ return (clip.inPoint ?? 0) - origin
56
65
  }
57
66
 
58
67
  /**
59
68
  * The outPoint in the loaded src's own timeline. For a normalizedSrc cache the
60
- * stored `clip.outPoint` is in ORIGINAL-source coordinates (e.g. 514.92) while
61
- * the cache plays in [0, outPoint - inPoint]; the boundary/loop checks compare
62
- * against `video.currentTime` (cache time), so the outPoint must be rebased to
63
- * the window length. Returns undefined when no outPoint is stored, so callers
64
- * keep their existing fallback (clip.end - clip.start + effectiveInPoint).
69
+ * stored `clip.outPoint` is in ORIGINAL-source coordinates while the cache
70
+ * plays from its own time origin; the boundary/loop checks compare against
71
+ * `video.currentTime` (cache time), so the outPoint must be rebased by the
72
+ * cache origin.
73
+ *
74
+ * The origin is `clip.normalizedInPoint ?? clip.inPoint ?? 0` (same as
75
+ * effectiveInPoint). Legacy clips without `normalizedInPoint` default the
76
+ * origin to inPoint, reproducing the old (outPoint - inPoint) window-length
77
+ * behavior.
78
+ *
79
+ * Returns undefined when no outPoint is stored, so callers keep their existing
80
+ * fallback (clip.end - clip.start + effectiveInPoint).
65
81
  */
66
- export function effectiveOutPoint(clip: { inPoint?: number; outPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number | undefined {
82
+ export function effectiveOutPoint(clip: { inPoint?: number; outPoint?: number; normalizedInPoint?: number; nobg_preview_src?: string; normalizedSrc?: string; src?: string }): number | undefined {
67
83
  if (clip.outPoint == null) return undefined
68
84
  const usingNormalizedCache = !clip.nobg_preview_src && !!clip.normalizedSrc
69
- return usingNormalizedCache ? clip.outPoint - (clip.inPoint ?? 0) : clip.outPoint
85
+ if (!usingNormalizedCache) return clip.outPoint
86
+ const origin = clip.normalizedInPoint ?? clip.inPoint ?? 0
87
+ return clip.outPoint - origin
70
88
  }
71
89
 
72
90
  export function useVideoPlayback(
@@ -6,6 +6,7 @@ export interface Draggable {
6
6
  end: number
7
7
  inPoint?: number
8
8
  outPoint?: number
9
+ normalizedInPoint?: number
9
10
  type?: string
10
11
  sourceDuration?: number
11
12
  }