@bycrux/editor 0.11.2 → 1.0.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 +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* Edge auto-scroll — while an item/trim/audio/caption drag holds the pointer
|
|
4
|
+
* near the surface's left or right edge, the view pans to follow it (standard
|
|
5
|
+
* NLE behaviour), rather than trapping the gesture at whatever was on screen
|
|
6
|
+
* when the drag started.
|
|
7
|
+
*
|
|
8
|
+
* The ramp/clamp MATH is `edgeScrollDelta` in viewport.ts, covered exhaustively
|
|
9
|
+
* as pure data in viewport.test.ts. What can only be shown with a mounted
|
|
10
|
+
* component is here: that a real drag actually starts the loop, that panning
|
|
11
|
+
* re-feeds the pointer machine so the dragged item keeps tracking, that it
|
|
12
|
+
* clamps and stops at the legal scroll range, and that it stands down when the
|
|
13
|
+
* pointer leaves the zone, the drag ends, or the gesture is a ruler scrub.
|
|
14
|
+
*
|
|
15
|
+
* jsdom's `performance.now()` is NOT tied to Vitest's fake timers (verified:
|
|
16
|
+
* advancing the fake clock by 16ms moves it by a fraction of a millisecond of
|
|
17
|
+
* REAL wall time), so the rAF loop's own frame-delta math would see near-zero
|
|
18
|
+
* `dt` if left alone — dozens of ticks to see any pan at all, and a flaky
|
|
19
|
+
* amount at that. `performance.now` is stubbed directly in these tests so each
|
|
20
|
+
* flushed tick reports a controlled elapsed time instead.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
23
|
+
import { render, act, cleanup } from '@testing-library/react'
|
|
24
|
+
import { createPlaybackClock } from '../../../playback-clock'
|
|
25
|
+
import type { Project } from '../../../../types'
|
|
26
|
+
import TimelineCanvas from '../TimelineCanvas'
|
|
27
|
+
import { createViewportStore, maxScrollSeconds, type Viewport } from '../viewport'
|
|
28
|
+
import { computeTimelineLayout } from '../draw'
|
|
29
|
+
|
|
30
|
+
let realGetContext: typeof HTMLCanvasElement.prototype.getContext
|
|
31
|
+
let realGetRect: typeof Element.prototype.getBoundingClientRect
|
|
32
|
+
|
|
33
|
+
function stubContext() {
|
|
34
|
+
return new Proxy({}, {
|
|
35
|
+
get(_t, prop: string) {
|
|
36
|
+
if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
|
|
37
|
+
return () => {}
|
|
38
|
+
},
|
|
39
|
+
set() { return true },
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
realGetContext = HTMLCanvasElement.prototype.getContext
|
|
45
|
+
realGetRect = Element.prototype.getBoundingClientRect
|
|
46
|
+
HTMLCanvasElement.prototype.getContext = (() => stubContext()) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
47
|
+
// Fixed 1000×100 surface at the page origin, matching the pointer suite's
|
|
48
|
+
// harness — the edge-zone math below (x<40 / x>960) is pinned against it.
|
|
49
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
50
|
+
return { x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 100, width: 1000, height: 100, toJSON: () => ({}) } as DOMRect
|
|
51
|
+
}
|
|
52
|
+
vi.useFakeTimers()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
cleanup()
|
|
57
|
+
vi.useRealTimers()
|
|
58
|
+
HTMLCanvasElement.prototype.getContext = realGetContext
|
|
59
|
+
Element.prototype.getBoundingClientRect = realGetRect
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// A wide project (well past what 1000px×100px/s shows at once) so there is
|
|
63
|
+
// real room to auto-scroll into.
|
|
64
|
+
const project = {
|
|
65
|
+
id: 'p',
|
|
66
|
+
tracks: [{
|
|
67
|
+
id: 'trk-0',
|
|
68
|
+
items: [
|
|
69
|
+
{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 4, inPoint: 0, outPoint: 4, sourceDuration: 20 },
|
|
70
|
+
{ id: 'c1', type: 'video', src: 'b.mp4', start: 4, end: 20, inPoint: 0, outPoint: 16, sourceDuration: 20 },
|
|
71
|
+
],
|
|
72
|
+
}],
|
|
73
|
+
} as unknown as Project
|
|
74
|
+
|
|
75
|
+
const ROW_Y = (() => {
|
|
76
|
+
const row = computeTimelineLayout(project).rows[0]
|
|
77
|
+
return Math.round(row.y + row.height / 2)
|
|
78
|
+
})()
|
|
79
|
+
|
|
80
|
+
const RULER_Y = (() => {
|
|
81
|
+
const ruler = computeTimelineLayout(project).ruler
|
|
82
|
+
return Math.round(ruler.y + ruler.height / 2)
|
|
83
|
+
})()
|
|
84
|
+
|
|
85
|
+
const TOTAL_DURATION = 60
|
|
86
|
+
const FPS = 30
|
|
87
|
+
|
|
88
|
+
// Pinned scale: pxPerSecond=100, widthPx=1000 → 10s visible. Rightmost legal
|
|
89
|
+
// scrollSeconds is then maxScrollSeconds(vp, 60) = 60 - 10 + 10×0.25 = 52.5.
|
|
90
|
+
const PINNED_VIEWPORT: Viewport = { pxPerSecond: 100, scrollSeconds: 0, widthPx: 1000 }
|
|
91
|
+
const RIGHTMOST_SCROLL = maxScrollSeconds(PINNED_VIEWPORT, TOTAL_DURATION)
|
|
92
|
+
|
|
93
|
+
function mount() {
|
|
94
|
+
const store = createViewportStore()
|
|
95
|
+
const clock = createPlaybackClock()
|
|
96
|
+
const onProjectChange = vi.fn()
|
|
97
|
+
const onOverlayEdit = vi.fn()
|
|
98
|
+
|
|
99
|
+
const utils = render(
|
|
100
|
+
<TimelineCanvas
|
|
101
|
+
project={project}
|
|
102
|
+
clock={clock}
|
|
103
|
+
store={store}
|
|
104
|
+
totalDuration={TOTAL_DURATION}
|
|
105
|
+
fps={FPS}
|
|
106
|
+
selectedIds={[]}
|
|
107
|
+
onProjectChange={onProjectChange}
|
|
108
|
+
onOverlayEdit={onOverlayEdit}
|
|
109
|
+
/>,
|
|
110
|
+
)
|
|
111
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
112
|
+
act(() => { store.set(PINNED_VIEWPORT) })
|
|
113
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
...utils,
|
|
117
|
+
store,
|
|
118
|
+
clock,
|
|
119
|
+
onProjectChange,
|
|
120
|
+
onOverlayEdit,
|
|
121
|
+
surface: utils.container.querySelector('[data-timeline-canvas]') as HTMLElement,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function mouse(type: string, x: number, y: number, init: MouseEventInit = {}) {
|
|
126
|
+
return new MouseEvent(type, { clientX: x, clientY: y, bubbles: true, cancelable: true, button: 0, ...init })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Installs a `performance.now()` stub the test fully controls: each call
|
|
130
|
+
* returns `clock.value`, which the test advances explicitly between ticks —
|
|
131
|
+
* decoupled from Vitest's fake timers (see file header). */
|
|
132
|
+
function stubPerfNow() {
|
|
133
|
+
const state = { value: 0 }
|
|
134
|
+
const spy = vi.spyOn(performance, 'now').mockImplementation(() => state.value)
|
|
135
|
+
return {
|
|
136
|
+
advance(ms: number) { state.value += ms },
|
|
137
|
+
restore() { spy.mockRestore() },
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** c0's start time in the most recent `projectChange` the gesture emitted.
|
|
142
|
+
* The `project` prop here is a fixed constant (nothing echoes edits back), so
|
|
143
|
+
* every emitted project is recomputed from the press-time one — the latest
|
|
144
|
+
* call is always the gesture's current position, never an accumulation. */
|
|
145
|
+
function lastC0Start(calls: unknown[][]): number {
|
|
146
|
+
expect(calls.length).toBeGreaterThan(0)
|
|
147
|
+
const emitted = calls[calls.length - 1][0] as unknown as {
|
|
148
|
+
tracks: { items: { id: string; start: number }[] }[]
|
|
149
|
+
}
|
|
150
|
+
// Searched across every track: dragging c0 out over c1 makes the mover lift
|
|
151
|
+
// it to a track of its own rather than overlap, so it is not on trk-0 by the
|
|
152
|
+
// time the pan starts.
|
|
153
|
+
const c0 = emitted.tracks.flatMap(t => t.items).find(i => i.id === 'c0')
|
|
154
|
+
expect(c0).toBeDefined()
|
|
155
|
+
return c0!.start
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
describe('TimelineCanvas — edge auto-scroll', () => {
|
|
159
|
+
it('pans the view toward the right edge during a drag, re-feeding the gesture, and clamps at the rightmost legal scroll', () => {
|
|
160
|
+
const perf = stubPerfNow()
|
|
161
|
+
try {
|
|
162
|
+
const { surface, store, onProjectChange } = mount()
|
|
163
|
+
|
|
164
|
+
// Press c0's body and cross the drag threshold well inside the surface.
|
|
165
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
166
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
|
|
167
|
+
const callsAfterRealMove = onProjectChange.mock.calls.length
|
|
168
|
+
expect(callsAfterRealMove).toBeGreaterThan(0)
|
|
169
|
+
expect(store.get().scrollSeconds).toBe(0)
|
|
170
|
+
|
|
171
|
+
// Drag to x=990 — inside the 40px right edge zone of a 1000px surface —
|
|
172
|
+
// and hold the pointer there without moving it further.
|
|
173
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
|
|
174
|
+
|
|
175
|
+
// First tick only seeds the clock (no prior timestamp to diff against);
|
|
176
|
+
// no pan yet.
|
|
177
|
+
act(() => { vi.advanceTimersByTime(20) })
|
|
178
|
+
expect(store.get().scrollSeconds).toBe(0)
|
|
179
|
+
|
|
180
|
+
// Each further tick pans right at a large, clock-controlled dt (capped
|
|
181
|
+
// internally at 0.1s/tick). x=990 sits 30px past the zone's boundary, so
|
|
182
|
+
// the eased ramp gives (30/70)^1.5 ≈ 0.28 of the 1400px/s cap — about
|
|
183
|
+
// 393px/s, i.e. ≈ 0.393s of scroll per tick at this scale — enough ticks
|
|
184
|
+
// to clear the RIGHTMOST_SCROLL clamp (52.5s) and then some, to prove it
|
|
185
|
+
// holds there rather than merely arriving at it.
|
|
186
|
+
for (let i = 0; i < 140; i++) {
|
|
187
|
+
perf.advance(1000)
|
|
188
|
+
act(() => { vi.advanceTimersByTime(20) })
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
expect(store.get().scrollSeconds).toBeCloseTo(RIGHTMOST_SCROLL, 5)
|
|
192
|
+
|
|
193
|
+
// The pointer's screen position never moved past x=990, yet the machine
|
|
194
|
+
// was re-fed that same point on every panning tick — proof the drag
|
|
195
|
+
// kept tracking as the view slid underneath it, not just on the two
|
|
196
|
+
// real mousemoves above.
|
|
197
|
+
expect(onProjectChange.mock.calls.length).toBeGreaterThan(callsAfterRealMove + 1)
|
|
198
|
+
|
|
199
|
+
// Stays clamped — no overshoot, no runaway past the legal range even
|
|
200
|
+
// with more frames available.
|
|
201
|
+
act(() => { vi.advanceTimersByTime(200) })
|
|
202
|
+
expect(store.get().scrollSeconds).toBeCloseTo(RIGHTMOST_SCROLL, 5)
|
|
203
|
+
|
|
204
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 990, ROW_Y)) })
|
|
205
|
+
} finally {
|
|
206
|
+
perf.restore()
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('carries the dragged clip along with the pan, so it stays under the held cursor', () => {
|
|
211
|
+
// The pan is only half the job. If the gesture measured its travel in raw
|
|
212
|
+
// screen pixels it would report NO movement for these frames — the pointer
|
|
213
|
+
// is parked — and the clip would sit at a fixed time while the timeline
|
|
214
|
+
// slid out from under it, drifting away from the hand holding it. What is
|
|
215
|
+
// asserted here is the other half: the clip advances by exactly the pan.
|
|
216
|
+
const perf = stubPerfNow()
|
|
217
|
+
try {
|
|
218
|
+
const { surface, store, onProjectChange } = mount()
|
|
219
|
+
|
|
220
|
+
// Grab c0 (0s–4s) 2.0s in, and drag to x=990 — inside the right edge
|
|
221
|
+
// zone — in one move. 9.9s under the cursor, less the 2.0s grab offset.
|
|
222
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
223
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
|
|
224
|
+
const beforePan = lastC0Start(onProjectChange.mock.calls)
|
|
225
|
+
expect(beforePan).toBeCloseTo(7.9, 5)
|
|
226
|
+
|
|
227
|
+
// Ten panning ticks with the pointer held exactly where it was. (The
|
|
228
|
+
// first tick only seeds the frame clock — see the test above.)
|
|
229
|
+
act(() => { vi.advanceTimersByTime(20) })
|
|
230
|
+
for (let i = 0; i < 10; i++) {
|
|
231
|
+
perf.advance(1000)
|
|
232
|
+
act(() => { vi.advanceTimersByTime(20) })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const scrolled = store.get().scrollSeconds
|
|
236
|
+
expect(scrolled).toBeGreaterThan(0)
|
|
237
|
+
// Far from the clamp, so this is the tracking behaviour and not the
|
|
238
|
+
// timeline's end holding the clip in place.
|
|
239
|
+
expect(scrolled).toBeLessThan(RIGHTMOST_SCROLL)
|
|
240
|
+
expect(lastC0Start(onProjectChange.mock.calls)).toBeCloseTo(beforePan + scrolled, 5)
|
|
241
|
+
|
|
242
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 990, ROW_Y)) })
|
|
243
|
+
} finally {
|
|
244
|
+
perf.restore()
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('stops panning as soon as the pointer moves back out of the edge zone', () => {
|
|
249
|
+
const perf = stubPerfNow()
|
|
250
|
+
try {
|
|
251
|
+
const { surface, store } = mount()
|
|
252
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
253
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
|
|
254
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
|
|
255
|
+
|
|
256
|
+
act(() => { vi.advanceTimersByTime(20) }) // seed
|
|
257
|
+
perf.advance(1000)
|
|
258
|
+
act(() => { vi.advanceTimersByTime(20) }) // one real pan
|
|
259
|
+
const pannedTo = store.get().scrollSeconds
|
|
260
|
+
expect(pannedTo).toBeGreaterThan(0)
|
|
261
|
+
expect(pannedTo).toBeLessThan(RIGHTMOST_SCROLL)
|
|
262
|
+
|
|
263
|
+
// Pull back to the middle of the surface — well outside either zone.
|
|
264
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 500, ROW_Y)) })
|
|
265
|
+
// The already-scheduled tick fires once more, sees the pointer is no
|
|
266
|
+
// longer in a zone, and stands the loop down without panning further.
|
|
267
|
+
perf.advance(1000)
|
|
268
|
+
act(() => { vi.advanceTimersByTime(20) })
|
|
269
|
+
expect(store.get().scrollSeconds).toBe(pannedTo)
|
|
270
|
+
|
|
271
|
+
// No further frames are pending — continuing to advance time changes
|
|
272
|
+
// nothing.
|
|
273
|
+
perf.advance(5000)
|
|
274
|
+
act(() => { vi.advanceTimersByTime(200) })
|
|
275
|
+
expect(store.get().scrollSeconds).toBe(pannedTo)
|
|
276
|
+
|
|
277
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 500, ROW_Y)) })
|
|
278
|
+
} finally {
|
|
279
|
+
perf.restore()
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('stops panning the instant the drag ends', () => {
|
|
284
|
+
const perf = stubPerfNow()
|
|
285
|
+
try {
|
|
286
|
+
const { surface, store } = mount()
|
|
287
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 200, ROW_Y)) })
|
|
288
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 250, ROW_Y)) })
|
|
289
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
|
|
290
|
+
|
|
291
|
+
act(() => { vi.advanceTimersByTime(20) }) // seed
|
|
292
|
+
perf.advance(1000)
|
|
293
|
+
act(() => { vi.advanceTimersByTime(20) }) // one real pan
|
|
294
|
+
const pannedTo = store.get().scrollSeconds
|
|
295
|
+
expect(pannedTo).toBeGreaterThan(0)
|
|
296
|
+
|
|
297
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 990, ROW_Y)) })
|
|
298
|
+
|
|
299
|
+
// The release tears the loop down synchronously (via the same teardown
|
|
300
|
+
// that removes the document listeners) — no leaked frame keeps panning.
|
|
301
|
+
perf.advance(5000)
|
|
302
|
+
act(() => { vi.advanceTimersByTime(200) })
|
|
303
|
+
expect(store.get().scrollSeconds).toBe(pannedTo)
|
|
304
|
+
} finally {
|
|
305
|
+
perf.restore()
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('does not auto-scroll for a ruler scrub, even with the pointer held at the edge', () => {
|
|
310
|
+
const perf = stubPerfNow()
|
|
311
|
+
try {
|
|
312
|
+
const { surface, store, clock } = mount()
|
|
313
|
+
// A press on the ruler scrubs immediately (dragging/scrub from the
|
|
314
|
+
// first pointerdown) — held right at the edge zone from the start.
|
|
315
|
+
act(() => { surface.dispatchEvent(mouse('mousedown', 990, RULER_Y)) })
|
|
316
|
+
expect(clock.get()).toBeCloseTo(9.9)
|
|
317
|
+
act(() => { document.dispatchEvent(mouse('mousemove', 990, RULER_Y)) })
|
|
318
|
+
|
|
319
|
+
perf.advance(1000)
|
|
320
|
+
act(() => { vi.advanceTimersByTime(200) })
|
|
321
|
+
perf.advance(5000)
|
|
322
|
+
act(() => { vi.advanceTimersByTime(200) })
|
|
323
|
+
|
|
324
|
+
// The playhead moved (that's what a scrub does); the VIEWPORT did not.
|
|
325
|
+
expect(store.get().scrollSeconds).toBe(0)
|
|
326
|
+
|
|
327
|
+
act(() => { document.dispatchEvent(mouse('mouseup', 990, RULER_Y)) })
|
|
328
|
+
} finally {
|
|
329
|
+
perf.restore()
|
|
330
|
+
}
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('never auto-scrolls with no active gesture', () => {
|
|
334
|
+
const perf = stubPerfNow()
|
|
335
|
+
try {
|
|
336
|
+
const { surface, store } = mount()
|
|
337
|
+
// Plain hover at the edge, no press.
|
|
338
|
+
act(() => { surface.dispatchEvent(mouse('mousemove', 990, ROW_Y)) })
|
|
339
|
+
perf.advance(1000)
|
|
340
|
+
act(() => { vi.advanceTimersByTime(200) })
|
|
341
|
+
expect(store.get().scrollSeconds).toBe(0)
|
|
342
|
+
} finally {
|
|
343
|
+
perf.restore()
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/// <reference types="vitest/globals" />
|
|
2
|
+
/**
|
|
3
|
+
* Pane-fill — the surface grows past its tracks to occupy the empty area at the
|
|
4
|
+
* bottom of the resizable timeline pane, so a click/drag there hits the canvas
|
|
5
|
+
* (deselect, seek, marquee) instead of dead page space.
|
|
6
|
+
*
|
|
7
|
+
* The arithmetic is proven as a pure unit (`paneFillHeight`); the wiring —
|
|
8
|
+
* measuring the marked scroll viewport, growing the surface, and hit-testing
|
|
9
|
+
* the extended region as `background` so a click deselects — needs a mounted
|
|
10
|
+
* surface. jsdom lays everything out at 0×0, so `getBoundingClientRect` is
|
|
11
|
+
* stubbed per-element: the scroll viewport is 600px tall, the surface starts
|
|
12
|
+
* 100px down, leaving 500px of fill below the ~186px of tracks.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
15
|
+
import { render, act, cleanup } from '@testing-library/react'
|
|
16
|
+
import { createPlaybackClock } from '../../../playback-clock'
|
|
17
|
+
import type { Project } from '../../../../types'
|
|
18
|
+
import TimelineCanvas, { paneFillHeight } from '../TimelineCanvas'
|
|
19
|
+
import { createViewportStore } from '../viewport'
|
|
20
|
+
|
|
21
|
+
describe('paneFillHeight', () => {
|
|
22
|
+
it('is the viewport height less the surface offset and the padding below it', () => {
|
|
23
|
+
expect(paneFillHeight(600, 100, 12)).toBe(488)
|
|
24
|
+
expect(paneFillHeight(600, 100, 0)).toBe(500)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('never goes negative when the surface offset already exceeds the viewport', () => {
|
|
28
|
+
// The tracks alone already overflow the pane: the fill floors at 0 and the
|
|
29
|
+
// caller's Math.max keeps the layout height instead.
|
|
30
|
+
expect(paneFillHeight(200, 260, 0)).toBe(0)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// One base video track (120px row) + one audio lane (40px): layout height ~186,
|
|
35
|
+
// comfortably under the 500px of pane fill the stubs produce.
|
|
36
|
+
const project = {
|
|
37
|
+
id: 'p',
|
|
38
|
+
tracks: [{ id: 'trk-0', items: [{ id: 'c0', type: 'video', src: 'a.mp4', start: 0, end: 8, inPoint: 0, outPoint: 8, sourceDuration: 8 }] }],
|
|
39
|
+
audio: { tracks: [{ id: 'a0', src: 'v.mp3', start: 1, end: 5 }] },
|
|
40
|
+
} as unknown as Project
|
|
41
|
+
|
|
42
|
+
let realGetContext: typeof HTMLCanvasElement.prototype.getContext
|
|
43
|
+
let realGetRect: typeof Element.prototype.getBoundingClientRect
|
|
44
|
+
|
|
45
|
+
function stubContext() {
|
|
46
|
+
return new Proxy({}, {
|
|
47
|
+
get(_t, prop: string) {
|
|
48
|
+
if (prop === 'createLinearGradient') return () => ({ addColorStop: () => {} })
|
|
49
|
+
return () => {}
|
|
50
|
+
},
|
|
51
|
+
set() { return true },
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function rect(top: number, bottom: number): DOMRect {
|
|
56
|
+
return { x: 0, y: top, top, left: 0, right: 1000, bottom, width: 1000, height: bottom - top, toJSON: () => ({}) } as DOMRect
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
realGetContext = HTMLCanvasElement.prototype.getContext
|
|
61
|
+
realGetRect = Element.prototype.getBoundingClientRect
|
|
62
|
+
HTMLCanvasElement.prototype.getContext = (() => stubContext()) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
63
|
+
// The scroll viewport is 600px tall; the surface begins 100px down it, so the
|
|
64
|
+
// fill below the tracks is 600 − 100 = 500px. Everything else keeps the 0×0
|
|
65
|
+
// that would otherwise apply.
|
|
66
|
+
Element.prototype.getBoundingClientRect = function (this: Element) {
|
|
67
|
+
if (this instanceof HTMLElement && this.hasAttribute('data-timeline-scroll')) return rect(0, 600)
|
|
68
|
+
if (this instanceof HTMLElement && this.hasAttribute('data-timeline-canvas')) return rect(100, 600)
|
|
69
|
+
return rect(0, 120)
|
|
70
|
+
}
|
|
71
|
+
vi.useFakeTimers()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
cleanup()
|
|
76
|
+
vi.useRealTimers()
|
|
77
|
+
HTMLCanvasElement.prototype.getContext = realGetContext
|
|
78
|
+
Element.prototype.getBoundingClientRect = realGetRect
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
function mount(overrides: Partial<React.ComponentProps<typeof TimelineCanvas>> = {}) {
|
|
82
|
+
const store = createViewportStore()
|
|
83
|
+
const clock = createPlaybackClock()
|
|
84
|
+
const onSelectItem = vi.fn()
|
|
85
|
+
const utils = render(
|
|
86
|
+
<div data-timeline-scroll>
|
|
87
|
+
<TimelineCanvas
|
|
88
|
+
project={project}
|
|
89
|
+
clock={clock}
|
|
90
|
+
store={store}
|
|
91
|
+
totalDuration={20}
|
|
92
|
+
fps={30}
|
|
93
|
+
selectedIds={[]}
|
|
94
|
+
onSelectItem={onSelectItem}
|
|
95
|
+
{...overrides}
|
|
96
|
+
/>
|
|
97
|
+
</div>,
|
|
98
|
+
)
|
|
99
|
+
act(() => { vi.advanceTimersByTime(32) })
|
|
100
|
+
const surface = utils.container.querySelector('[data-timeline-canvas]') as HTMLElement
|
|
101
|
+
return { ...utils, store, clock, onSelectItem, surface }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('TimelineCanvas — pane fill', () => {
|
|
105
|
+
it('grows the surface to fill the pane below the tracks', () => {
|
|
106
|
+
const { surface } = mount()
|
|
107
|
+
// 500px of fill dominates the ~186px of tracks.
|
|
108
|
+
expect(surface.style.height).toBe('500px')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('deselects on a click in the extended area below the tracks', () => {
|
|
112
|
+
// y=400 is well past the last lane (~186) but inside the 500px surface, so
|
|
113
|
+
// it hit-tests as background — a press+release there clears the selection,
|
|
114
|
+
// exactly as clicking bare timeline does.
|
|
115
|
+
const { surface, onSelectItem } = mount({ selectedIds: ['c0'] })
|
|
116
|
+
act(() => {
|
|
117
|
+
surface.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 400, clientY: 400, bubbles: true }))
|
|
118
|
+
surface.dispatchEvent(new MouseEvent('mouseup', { button: 0, clientX: 400, clientY: 400, bubbles: true }))
|
|
119
|
+
})
|
|
120
|
+
expect(onSelectItem).toHaveBeenCalledWith(null, false)
|
|
121
|
+
})
|
|
122
|
+
})
|