@bycrux/editor 0.9.0 → 0.10.0

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.9.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -112,6 +112,10 @@ export type { TextFormattingToolbarProps } from './text/TextFormattingToolbar'
112
112
  export { OverlayPreview } from './preview/OverlayPreview'
113
113
  export type { OverlayPreviewProps } from './preview/OverlayPreview'
114
114
 
115
+ // ── Playback clock (external playhead store) ──────────────────────────────────
116
+ export { createPlaybackClock, usePlaybackTime } from './video/playback-clock'
117
+ export type { PlaybackClock } from './video/playback-clock'
118
+
115
119
  // ── Video preview ─────────────────────────────────────────────────────────────
116
120
  export { default as PreviewPlayer } from './video/preview/PreviewPlayer'
117
121
  export { default as CarouselPreview } from './video/preview/CarouselPreview'
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
- import { Crop, Info, Magnet, Undo2 } from 'lucide-react'
2
+ import { Crop, Info, Magnet, Pencil, Undo2 } from 'lucide-react'
3
3
  import type { Project, VideoEditorProps } from '../types'
4
4
  import { VideoSourceCropModal } from '../crop/VideoSourceCropModal'
5
5
  import ControlsInfoModal, { VIDEO_CONTROLS } from '../ControlsInfoModal'
@@ -9,9 +9,12 @@ import { applyCutToItem, applyCutToTracks, collapseGaps, splitAtTime } from './c
9
9
  import { repairCaptionWords } from './captionRepair'
10
10
  import Timeline from './timeline/Timeline'
11
11
  import PreviewPlayer from './preview/PreviewPlayer'
12
+ import { createPlaybackClock, type PlaybackClock } from './playback-clock'
13
+ import type { OverlayChanges } from './preview/useDragOverlay'
12
14
  import VersionPanel from './VersionPanel'
13
15
  import RenderModal from './RenderModal'
14
16
  import CaptionRegenModal from './CaptionRegenModal'
17
+ import OverlayPropsModal from './preview/OverlayPropsModal'
15
18
 
16
19
  // Generic over the host's concrete project type `P` (default = the package's
17
20
  // own `Project`). Montaj passes its richer Project; the index signature on
@@ -140,7 +143,11 @@ function PendingSurface<P extends Project>({
140
143
  getWaveformChunks,
141
144
  resolveFilePath,
142
145
  }: SurfaceProps<P> & { onBackToSetup?: () => void }) {
143
- const [currentTime, setCurrentTime] = useState(0)
146
+ // The playhead lives in an external store (not useState) so ~60Hz ticks only
147
+ // re-render the leaves that display time — not this whole surface.
148
+ const clockRef = useRef<PlaybackClock | null>(null)
149
+ if (!clockRef.current) clockRef.current = createPlaybackClock()
150
+ const clock = clockRef.current
144
151
  const [skillPath, setSkillPath] = useState<string | null>(null)
145
152
  const [copied, setCopied] = useState(false)
146
153
  const { versions, restoring, setRestoring } = useVersionHistory(adapter, project)
@@ -177,8 +184,7 @@ function PendingSurface<P extends Project>({
177
184
  {hasTrimmedClips ? (
178
185
  <PreviewPlayer
179
186
  project={project}
180
- currentTime={currentTime}
181
- onTimeUpdate={setCurrentTime}
187
+ clock={clock}
182
188
  compileOverlay={adapter.compileOverlay}
183
189
  clearOverlayCache={adapter.clearOverlayCache}
184
190
  watchFile={adapter.watchFile}
@@ -238,8 +244,7 @@ function PendingSurface<P extends Project>({
238
244
  <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
239
245
  <Timeline
240
246
  project={project}
241
- currentTime={currentTime}
242
- onTimeUpdate={setCurrentTime}
247
+ clock={clock}
243
248
  getWaveformChunks={getWaveformChunks}
244
249
  resolveFilePath={resolveFilePath}
245
250
  onSaveProject={(p) => adapter.saveProject(p.id, p as P)}
@@ -280,7 +285,12 @@ function ReviewSurface<P extends Project>({
280
285
  regenEnabled?: boolean
281
286
  isClipQueued?: (itemId: string) => boolean
282
287
  }) {
283
- const [currentTime, setCurrentTime] = useState(0)
288
+ // Playhead in an external store, not useState — ~60Hz ticks re-render only the
289
+ // leaves that display time (preview, scrubber, transcript) instead of the whole
290
+ // review surface (toolbar + timeline + every context consumer).
291
+ const clockRef = useRef<PlaybackClock | null>(null)
292
+ if (!clockRef.current) clockRef.current = createPlaybackClock()
293
+ const clock = clockRef.current
284
294
  const [canUndo, setCanUndo] = useState(false)
285
295
  const historyRef = useRef<P[]>([])
286
296
  // Multi-select: all currently-selected timeline item ids. Single-select
@@ -343,6 +353,55 @@ function ReviewSurface<P extends Project>({
343
353
  if (!cropTarget && cropMode) setCropMode(false)
344
354
  }, [cropTarget, cropMode])
345
355
 
356
+ // Overlay props dialog — opened from the preview (double-click), the controls
357
+ // bar, or the timeline block. VideoEditor owns the state so all three surfaces
358
+ // share one modal. Edits ride handleOverlayChange (history + save for free).
359
+ const [editingOverlayId, setEditingOverlayId] = useState<string | null>(null)
360
+ // Project snapshot taken when the dialog opens, so history/undo and Cancel
361
+ // revert to the pre-edit state even though edits preview live in between.
362
+ const editOriginalRef = useRef<P | null>(null)
363
+ const requestEditOverlay = useCallback((id: string) => {
364
+ editOriginalRef.current = projectRef.current
365
+ setEditingOverlayId(id)
366
+ }, [])
367
+ const allVisualItems = (project.tracks ?? []).flat()
368
+ const editingOverlayItem = editingOverlayId
369
+ ? allVisualItems.find(i => i.id === editingOverlayId) ?? null
370
+ : null
371
+
372
+ function withItemProps(id: string, nextProps: Record<string, unknown>): P {
373
+ return {
374
+ ...project,
375
+ tracks: (project.tracks ?? []).map(track =>
376
+ track.map(item => (item.id !== id ? item : { ...item, props: nextProps })),
377
+ ),
378
+ } as P
379
+ }
380
+ // Live preview: reflect the in-progress edit via onProjectChange only — no
381
+ // history push, no save — so the overlay re-renders as the operator tweaks.
382
+ function previewOverlayProps(id: string, nextProps: Record<string, unknown>) {
383
+ onProjectChange(withItemProps(id, nextProps))
384
+ }
385
+ // Commit on Save: snapshot the pre-edit project for undo, then persist.
386
+ function commitOverlayEdit(id: string, nextProps: Record<string, unknown>) {
387
+ if (editOriginalRef.current) pushHistory(editOriginalRef.current)
388
+ const updated = withItemProps(id, nextProps)
389
+ onProjectChange(updated)
390
+ save(updated)
391
+ editOriginalRef.current = null
392
+ setEditingOverlayId(null)
393
+ }
394
+ // Cancel/Esc/close: discard the live preview by restoring the snapshot.
395
+ function cancelOverlayEdit() {
396
+ if (editOriginalRef.current) onProjectChange(editOriginalRef.current)
397
+ editOriginalRef.current = null
398
+ setEditingOverlayId(null)
399
+ }
400
+ // The primary-selected JSX overlay, if any — drives the controls-bar edit button.
401
+ const selectedOverlayItem = primarySelectedId
402
+ ? allVisualItems.find(i => i.id === primarySelectedId && i.type === 'overlay' && !!i.src) ?? null
403
+ : null
404
+
346
405
  function pushHistory(prev: P) {
347
406
  historyRef.current = [...historyRef.current.slice(-49), prev]
348
407
  setCanUndo(true)
@@ -377,7 +436,7 @@ function ReviewSurface<P extends Project>({
377
436
  setSelectedIds([])
378
437
  }
379
438
 
380
- function handleOverlayChange(id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill'; sourceCrop?: { x: number; y: number; w: number; h: number }; sourceWidth?: number; sourceHeight?: number }) {
439
+ function handleOverlayChange(id: string, changes: OverlayChanges) {
381
440
  pushHistory(project)
382
441
  const updated = {
383
442
  ...project,
@@ -390,7 +449,7 @@ function ReviewSurface<P extends Project>({
390
449
  }
391
450
 
392
451
  function handleSplit(at?: number) {
393
- const updated = splitAtTime(project, at ?? currentTime, primarySelectedId ?? null)
452
+ const updated = splitAtTime(project, at ?? clock.get(), primarySelectedId ?? null)
394
453
  if (updated === project) return
395
454
  pushHistory(project)
396
455
  onProjectChange(updated as P)
@@ -420,7 +479,7 @@ function ReviewSurface<P extends Project>({
420
479
  }
421
480
  document.addEventListener('keydown', onKey)
422
481
  return () => document.removeEventListener('keydown', onKey)
423
- }, [project, currentTime, primarySelectedId, canUndo])
482
+ }, [project, primarySelectedId, canUndo])
424
483
 
425
484
  async function handleRestoreVersion(hash: string) {
426
485
  if (!adapter.restoreVersion) return
@@ -449,10 +508,10 @@ function ReviewSurface<P extends Project>({
449
508
  >
450
509
  <PreviewPlayer
451
510
  project={project}
452
- currentTime={currentTime}
453
- onTimeUpdate={setCurrentTime}
511
+ clock={clock}
454
512
  selectedOverlayId={primarySelectedId ?? undefined}
455
513
  onOverlayChange={handleOverlayChange}
514
+ onEditOverlay={requestEditOverlay}
456
515
  compileOverlay={adapter.compileOverlay}
457
516
  clearOverlayCache={adapter.clearOverlayCache}
458
517
  watchFile={adapter.watchFile}
@@ -524,6 +583,16 @@ function ReviewSurface<P extends Project>({
524
583
  >
525
584
  <Crop size={12} />
526
585
  </button>
586
+ {selectedOverlayItem && (
587
+ <button
588
+ onClick={() => requestEditOverlay(selectedOverlayItem.id)}
589
+ title="Edit overlay — text, colors, and other properties"
590
+ aria-label="Edit overlay"
591
+ className="flex items-center justify-center w-5 h-5 rounded transition-colors text-[var(--editor-text)]/60 bg-transparent hover:text-[var(--editor-text)]"
592
+ >
593
+ <Pencil size={12} />
594
+ </button>
595
+ )}
527
596
  {/* Default placement. A host that sets onProvideRenderTrigger renders
528
597
  Render in its own chrome instead, so the toolbar button is hidden. */}
529
598
  {!onProvideRenderTrigger && (
@@ -539,11 +608,11 @@ function ReviewSurface<P extends Project>({
539
608
  <div className="shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-surface)]">
540
609
  <Timeline
541
610
  project={project}
542
- currentTime={currentTime}
543
- onTimeUpdate={setCurrentTime}
611
+ clock={clock}
544
612
  onProjectChange={handleProjectChange}
545
613
  onCaptionEdit={(p) => { onProjectChange(p as P); save(p as P) }}
546
614
  onOverlayEdit={(p) => { onProjectChange(p as P); save(p as P) }}
615
+ onEditOverlay={requestEditOverlay}
547
616
  selectedIds={selectedIds}
548
617
  onSelectIds={setSelectedIds}
549
618
  onSplit={handleSplit}
@@ -670,6 +739,21 @@ function ReviewSurface<P extends Project>({
670
739
  />
671
740
  )}
672
741
 
742
+ {/* Overlay props dialog — edits the selected overlay's primitive props
743
+ (text, colors, numbers, toggles). Opened from the preview double-click,
744
+ the controls bar, or a timeline block. Rides handleOverlayChange, so
745
+ edits preview live and undo like any other change. */}
746
+ {editingOverlayItem && (
747
+ <OverlayPropsModal
748
+ itemProps={editingOverlayItem.props ?? {}}
749
+ fileUrl={adapter.fileUrl}
750
+ uploadFile={(file) => adapter.uploadFile(file, project.id)}
751
+ onPreview={(next) => previewOverlayProps(editingOverlayItem.id, next)}
752
+ onSave={(next) => commitOverlayEdit(editingOverlayItem.id, next)}
753
+ onClose={cancelOverlayEdit}
754
+ />
755
+ )}
756
+
673
757
  {/* Clip / audio inspector — host-rendered via render-prop seam. */}
674
758
  {inspecting && renderClipInspector?.({
675
759
  item: inspecting,
@@ -8,6 +8,8 @@ import type {
8
8
  VersionEntry,
9
9
  WaveformChunk,
10
10
  } from '../../types'
11
+ import type { VisualItem } from '../../schema'
12
+ import type { OverlayChanges } from '../preview/useDragOverlay'
11
13
  import VideoEditor from '../VideoEditor'
12
14
 
13
15
  // ── Fake adapter ──────────────────────────────────────────────────────────────
@@ -210,4 +212,36 @@ describe('VideoEditor — editor-package integration', () => {
210
212
  // The copy-able prompt text should include the skill path
211
213
  await findByText(/skills\/video-skill\.md/i)
212
214
  })
215
+
216
+ // `handleOverlayChange` (VideoEditor.tsx) is a private closure whose `changes`
217
+ // param is typed as `OverlayChanges` (useDragOverlay.ts) and whose body is
218
+ // exactly `{ ...item, ...changes }`. No control in the preview layer currently
219
+ // drives a `props` payload through it — the crop modal only ever sends
220
+ // sourceCrop/sourceWidth/sourceHeight, and drag/resize/rotate only ever send
221
+ // offsetX/offsetY/scale/rotation — so there is no DOM path in this harness
222
+ // that reaches a `props` change via a mounted <VideoEditor>. This test instead
223
+ // exercises the real merge contract directly: `changes` is typed against the
224
+ // actual `OverlayChanges` export (so `props` only compiles once useDragOverlay
225
+ // declares it), and the assertion applies the identical spread
226
+ // `handleOverlayChange` performs.
227
+ it('handleOverlayChange merges a props payload into the matching item without touching other fields', () => {
228
+ const item: VisualItem = {
229
+ id: 'overlay-1',
230
+ type: 'overlay',
231
+ src: 'overlay.jsx',
232
+ start: 0,
233
+ end: 4,
234
+ offsetX: 5,
235
+ offsetY: 10,
236
+ props: { text: 'Old text' },
237
+ }
238
+ const changes: OverlayChanges = { props: { text: 'New text' } }
239
+
240
+ // Mirrors VideoEditor.tsx handleOverlayChange's item-update line exactly.
241
+ const merged = { ...item, ...changes }
242
+
243
+ expect(merged.props).toEqual({ text: 'New text' })
244
+ expect(merged.offsetX).toBe(5)
245
+ expect(merged.offsetY).toBe(10)
246
+ })
213
247
  })
@@ -0,0 +1,45 @@
1
+ import { render, act, screen } from '@testing-library/react'
2
+ import { createPlaybackClock, usePlaybackTime } from '../playback-clock'
3
+
4
+ test('set notifies subscribers and get returns latest', () => {
5
+ const clock = createPlaybackClock()
6
+ let seen = -1
7
+ const unsub = clock.subscribe(() => { seen = clock.get() })
8
+ clock.set(1.5)
9
+ expect(seen).toBe(1.5)
10
+ expect(clock.get()).toBe(1.5)
11
+ unsub()
12
+ clock.set(2)
13
+ expect(seen).toBe(1.5)
14
+ })
15
+
16
+ test('set with an unchanged value does not notify', () => {
17
+ const clock = createPlaybackClock()
18
+ let calls = 0
19
+ clock.subscribe(() => calls++)
20
+ clock.set(1); clock.set(1)
21
+ expect(calls).toBe(1)
22
+ })
23
+
24
+ test('only subscribing components re-render on tick', () => {
25
+ const clock = createPlaybackClock()
26
+ let siblingRenders = 0
27
+
28
+ function TimeDisplay() {
29
+ const t = usePlaybackTime(clock)
30
+ return <span data-testid="t">{t}</span>
31
+ }
32
+ function Sibling() {
33
+ siblingRenders++
34
+ return null
35
+ }
36
+ function Root() {
37
+ return (<><TimeDisplay /><Sibling /></>)
38
+ }
39
+
40
+ render(<Root />)
41
+ const before = siblingRenders
42
+ act(() => { clock.set(3.25) })
43
+ expect(screen.getByTestId('t').textContent).toBe('3.25')
44
+ expect(siblingRenders).toBe(before)
45
+ })
@@ -0,0 +1,31 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ export interface PlaybackClock {
4
+ get(): number
5
+ set(t: number): void
6
+ subscribe(cb: () => void): () => void
7
+ }
8
+
9
+ /** External store for the playhead. Written ~60Hz by the playback engine;
10
+ * only components that render the time subscribe (usePlaybackTime). Event
11
+ * handlers that just need "time right now" call clock.get() — no re-renders. */
12
+ export function createPlaybackClock(initial = 0): PlaybackClock {
13
+ let time = initial
14
+ const subs = new Set<() => void>()
15
+ return {
16
+ get: () => time,
17
+ set(t) {
18
+ if (t === time) return
19
+ time = t
20
+ subs.forEach((cb) => cb())
21
+ },
22
+ subscribe(cb) {
23
+ subs.add(cb)
24
+ return () => { subs.delete(cb) }
25
+ },
26
+ }
27
+ }
28
+
29
+ export function usePlaybackTime(clock: PlaybackClock): number {
30
+ return useSyncExternalStore(clock.subscribe, clock.get, clock.get)
31
+ }
@@ -1,10 +1,10 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { EditorProject as Project, VisualItem } from '../../schema'
3
3
  import type { OverlayFactory } from '../../types'
4
4
  import OverlayErrorBoundary from '../../carousel/OverlayErrorBoundary'
5
5
  import { getOverlayDesignCanvas } from '../design-canvas'
6
6
  import { ensureGoogleFontsLoaded } from '../../lib/google-fonts'
7
- import type { Corner } from './useDragOverlay'
7
+ import type { Corner, OverlayChanges } from './useDragOverlay'
8
8
  import type { useDragOverlay } from './useDragOverlay'
9
9
 
10
10
  const VIDEO_PRELOAD_S = 0.4 // mount this many seconds before item.start so the frame is ready
@@ -171,6 +171,14 @@ function CustomOverlay({
171
171
  return () => unwatch()
172
172
  }, [src, watchFile, compile])
173
173
 
174
+ // Deep-clone/rewrite the props once per props change instead of every frame.
175
+ // Live prop edits (OverlayPropsModal → VideoEditor.withItemProps) always
176
+ // produce a new `props` object reference, so this recomputes on every edit.
177
+ const resolvedProps = useMemo(
178
+ () => resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>,
179
+ [props, fileUrl],
180
+ )
181
+
174
182
  if (error) {
175
183
  return (
176
184
  <div className="absolute bottom-4 left-4 right-4 pointer-events-none">
@@ -183,8 +191,6 @@ function CustomOverlay({
183
191
 
184
192
  if (!factory) return null
185
193
 
186
- const resolvedProps = resolveOverlayPropPaths(props, fileUrl) as Record<string, unknown>
187
-
188
194
  const element = factory(frame, fps, durationFrames, resolvedProps)
189
195
  if (!element) return null
190
196
 
@@ -288,7 +294,9 @@ interface OverlayItemsLayerProps {
288
294
  tracks0NonVideo: VisualItem[]
289
295
  renderScale: number
290
296
  selectedOverlayId?: string
291
- onOverlayChange?: (id: string, changes: { offsetX?: number; offsetY?: number; scale?: number; rotation?: number; fit?: 'cover' | 'contain' | 'fill' }) => void
297
+ onOverlayChange?: (id: string, changes: OverlayChanges) => void
298
+ /** Open the props dialog for an overlay (owned by VideoEditor). */
299
+ onEditOverlay?: (id: string) => void
292
300
  containerRef: React.RefObject<HTMLDivElement | null>
293
301
  // from useDragOverlay
294
302
  dragState: ReturnType<typeof useDragOverlay>['dragState']
@@ -315,6 +323,7 @@ export default function OverlayItemsLayer({
315
323
  renderScale,
316
324
  selectedOverlayId,
317
325
  onOverlayChange,
326
+ onEditOverlay,
318
327
  containerRef,
319
328
  dragState,
320
329
  setDragState,
@@ -330,6 +339,9 @@ export default function OverlayItemsLayer({
330
339
  }: OverlayItemsLayerProps) {
331
340
  const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
332
341
 
342
+ // Interactive tracks — in canvas mode this includes track 0; otherwise overlays only.
343
+ const interactiveTracks = isCanvasProject ? project.tracks ?? [] : overlayTracks
344
+
333
345
  return (
334
346
  <>
335
347
  {/* tracks[0] non-video items (background images) — rendered with drag support at base z-level */}
@@ -395,7 +407,7 @@ export default function OverlayItemsLayer({
395
407
  })}
396
408
 
397
409
  {/* All interactive tracks — in canvas mode this includes track 0; otherwise overlays only */}
398
- {(isCanvasProject ? project.tracks ?? [] : overlayTracks).map((trackItems, trackIdx) =>
410
+ {interactiveTracks.map((trackItems, trackIdx) =>
399
411
  trackItems.map((item) => {
400
412
  const visible = currentTime >= item.start && currentTime < item.end
401
413
  // Pre-mount video items slightly before their start so the frame is ready (no flash)
@@ -433,6 +445,14 @@ export default function OverlayItemsLayer({
433
445
  setDragState({ id: item.id, type: 'rotate', initX: e.clientX, initY: e.clientY, initOffsetX: offsetX, initOffsetY: offsetY, initScale: scale, initRotation: rotation, cx, cy, initAngle })
434
446
  }
435
447
 
448
+ // Double-click a selected JSX overlay opens the props dialog (owned by
449
+ // VideoEditor). stopPropagation keeps it from re-triggering startMove.
450
+ function handleDoubleClick(e: React.MouseEvent) {
451
+ if (!isSel) return
452
+ e.stopPropagation()
453
+ if (item.type === 'overlay' && item.src) onEditOverlay?.(item.id)
454
+ }
455
+
436
456
  // zIndex: canvas mode track 0 sits just above the play-toggle div (10), others stack above
437
457
  const zIndex = isCanvasProject ? trackIdx + 11 : trackIdx + 12
438
458
 
@@ -501,14 +521,16 @@ export default function OverlayItemsLayer({
501
521
  const frame = Math.round((currentTime - item.start) * fps)
502
522
  const durationFrames = Math.round((item.end - item.start) * fps)
503
523
  return (
504
- <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove}>
524
+ <div key={item.id} className={wrapperClass} style={wrapperStyle} onMouseDown={startMove} onDoubleClick={handleDoubleClick}>
505
525
  {/* Render at native 1080×1920 then scale down to match container */}
506
- <div style={{
507
- position: 'absolute', top: 0, left: 0,
508
- width: RENDER_W, height: RENDER_H,
509
- transform: `scale(${renderScale})`, transformOrigin: 'top left',
510
- pointerEvents: 'none',
511
- }}>
526
+ <div
527
+ style={{
528
+ position: 'absolute', top: 0, left: 0,
529
+ width: RENDER_W, height: RENDER_H,
530
+ transform: `scale(${renderScale})`, transformOrigin: 'top left',
531
+ pointerEvents: 'none',
532
+ }}
533
+ >
512
534
  <OverlayErrorBoundary
513
535
  label={item.src.split('/').pop() ?? item.src}
514
536
  watchPath={item.src}
@@ -600,6 +622,7 @@ export default function OverlayItemsLayer({
600
622
  </svg>
601
623
  </div>
602
624
  )}
625
+
603
626
  </>
604
627
  )
605
628
  }