@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
|
@@ -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
|
|
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={
|
|
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
|
|
35
|
+
* normalizedSrc cache origin.
|
|
36
36
|
*
|
|
37
|
-
* A `normalizedSrc` cache
|
|
38
|
-
*
|
|
39
|
-
* `playbackSrcFor` chooses
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
|
|
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(
|