From b865064d653d48e603edffcca4e21f23db3c3d10 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Tue, 19 May 2026 17:57:21 -0700 Subject: [PATCH] fix: stabilize the preview canvas inside of the region during resizing --- src/components/interfaces.ts | 5 ++ src/components/track/Region.css | 14 +++ src/components/track/RegionItem.test.tsx | 31 +++++++ src/components/track/RegionItem.tsx | 58 ++++++++----- src/components/track/TrackGridItem.test.tsx | 95 +++++++++++++++++++++ src/components/track/TrackGridItem.tsx | 67 ++++++++++++++- src/components/track/TrackGridPanel.tsx | 5 +- 7 files changed, 251 insertions(+), 24 deletions(-) diff --git a/src/components/interfaces.ts b/src/components/interfaces.ts index 5451700..4f7367c 100644 --- a/src/components/interfaces.ts +++ b/src/components/interfaces.ts @@ -18,6 +18,11 @@ export interface RegionUI { name: string; } +export interface RegionPreviewContentStyle { + left: string; + width: string; +} + export interface RegionClickOptions { shiftKey: boolean; } diff --git a/src/components/track/Region.css b/src/components/track/Region.css index e112248..1de1aeb 100644 --- a/src/components/track/Region.css +++ b/src/components/track/Region.css @@ -73,12 +73,26 @@ background-color: #87CEFA; /* Light blue */ width: 100%; position: relative; /* Allow overlayed controls */ + overflow: hidden; } .region-content.audio-region-content { background-color: #90EE90; /* Light green for audio regions */ } +.region-preview-content { + position: absolute; + inset: 0 auto 0 0; + width: 100%; + height: 100%; +} + +.region-preview-content canvas { + display: block; + width: 100%; + height: 100%; +} + /* Audio region overrides */ .track-region.audio-region { background-color: #3a6b4a; diff --git a/src/components/track/RegionItem.test.tsx b/src/components/track/RegionItem.test.tsx index 5076ff0..7164275 100644 --- a/src/components/track/RegionItem.test.tsx +++ b/src/components/track/RegionItem.test.tsx @@ -147,4 +147,35 @@ describe('RegionItem', () => { expect(context.stroke).toHaveBeenCalled(); rectSpy.mockRestore(); }); + + it('applies preview content clipping styles when provided', () => { + const { container } = renderRegion({ + previewContentStyle: { + left: '-40px', + width: '120px', + }, + }); + + const previewContent = container.querySelector('.region-preview-content'); + + expect(previewContent).toBeTruthy(); + expect(previewContent).toHaveAttribute('data-preview-content-active', 'true'); + expect(previewContent).toHaveStyle({ + left: '-40px', + width: '120px', + }); + }); + + it('uses the default preview content wrapper sizing for normal regions', () => { + const { container } = renderRegion(); + + const previewContent = container.querySelector('.region-preview-content'); + + expect(previewContent).toBeTruthy(); + expect(previewContent).toHaveAttribute('data-preview-content-active', 'false'); + expect(previewContent).not.toHaveStyle({ + left: '-40px', + width: '120px', + }); + }); }); diff --git a/src/components/track/RegionItem.tsx b/src/components/track/RegionItem.tsx index ed5a17a..f9eef8b 100644 --- a/src/components/track/RegionItem.tsx +++ b/src/components/track/RegionItem.tsx @@ -9,6 +9,7 @@ import { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { useProjectStore } from '../../stores/projectStore'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import type { AudioRecordingPeak } from '../../core/audio-interface/KGAudioRecorder'; +import type { RegionPreviewContentStyle } from '../interfaces'; const DRAG_START_THRESHOLD_PX = 4; @@ -46,6 +47,7 @@ interface RegionItemProps { previewWaveformPeaks?: AudioRecordingPeak[]; isPreview?: boolean; isAudioRegion?: boolean; + previewContentStyle?: RegionPreviewContentStyle; } const RegionItem: React.FC = ({ @@ -73,6 +75,7 @@ const RegionItem: React.FC = ({ previewWaveformPeaks, isPreview = false, isAudioRegion = false, + previewContentStyle, }) => { // Get selection state and time signature from store const { selectedRegionIds, timeSignature, bpm } = useProjectStore(); @@ -98,24 +101,26 @@ const RegionItem: React.FC = ({ // Canvas ref for note visualization const canvasRef = useRef(null); - const regionContentRef = useRef(null); + const previewContentRef = useRef(null); // Function to render notes on canvas const renderNotesOnCanvas = () => { - if (!canvasRef.current || !regionContentRef.current || !midiRegion) return; + if (!canvasRef.current || !previewContentRef.current || !midiRegion) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; // Get the current dimensions of the region content - const contentRect = regionContentRef.current.getBoundingClientRect(); - const width = contentRect.width; - const height = contentRect.height; + const contentRect = previewContentRef.current.getBoundingClientRect(); + const width = Math.max(1, Math.round(contentRect.width)); + const height = Math.max(1, Math.round(contentRect.height)); // Set canvas size to match the region content canvas.width = width; canvas.height = height; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; // Clear the canvas ctx.clearRect(0, 0, width, height); @@ -242,18 +247,20 @@ const RegionItem: React.FC = ({ // Function to render audio waveform on canvas const renderWaveformOnCanvas = () => { - if (!canvasRef.current || !regionContentRef.current || !audioBuffer) return; + if (!canvasRef.current || !previewContentRef.current || !audioBuffer) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; - const contentRect = regionContentRef.current.getBoundingClientRect(); - const width = contentRect.width; - const height = contentRect.height; + const contentRect = previewContentRef.current.getBoundingClientRect(); + const width = Math.max(1, Math.round(contentRect.width)); + const height = Math.max(1, Math.round(contentRect.height)); canvas.width = width; canvas.height = height; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; ctx.clearRect(0, 0, width, height); @@ -311,18 +318,20 @@ const RegionItem: React.FC = ({ }; const renderPreviewWaveformOnCanvas = () => { - if (!canvasRef.current || !regionContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return; + if (!canvasRef.current || !previewContentRef.current || !previewWaveformPeaks || previewWaveformPeaks.length === 0) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; - const contentRect = regionContentRef.current.getBoundingClientRect(); - const width = contentRect.width; - const height = contentRect.height; + const contentRect = previewContentRef.current.getBoundingClientRect(); + const width = Math.max(1, Math.round(contentRect.width)); + const height = Math.max(1, Math.round(contentRect.height)); canvas.width = width; canvas.height = height; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; ctx.clearRect(0, 0, width, height); const centerY = height / 2; @@ -374,11 +383,11 @@ const RegionItem: React.FC = ({ } else { renderNotesOnCanvas(); } - }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length]); + }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, id, noteUpdateTrigger, barNumber, length, previewContentStyle?.width]); // Re-render canvas when region content size changes useEffect(() => { - if (!regionContentRef.current) return; + if (!previewContentRef.current) return; const resizeObserver = new ResizeObserver(() => { if (previewWaveformPeaks && previewWaveformPeaks.length > 0) { @@ -390,14 +399,14 @@ const RegionItem: React.FC = ({ } }); - resizeObserver.observe(regionContentRef.current); + resizeObserver.observe(previewContentRef.current); return () => { - if (regionContentRef.current) { - resizeObserver.unobserve(regionContentRef.current); + if (previewContentRef.current) { + resizeObserver.unobserve(previewContentRef.current); } }; - }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm]); + }, [midiRegion, audioRegion, audioBuffer, previewWaveformPeaks, timeSignature, bpm, previewContentStyle?.width]); // Handle mouse movement to detect edge proximity const handleMouseMove = (e: React.MouseEvent) => { @@ -668,7 +677,7 @@ const RegionItem: React.FC = ({
{name}
-
+
{!isPreview &&
{!audioRegion && (
} - +
+ +
); diff --git a/src/components/track/TrackGridItem.test.tsx b/src/components/track/TrackGridItem.test.tsx index b26e3f6..288d7aa 100644 --- a/src/components/track/TrackGridItem.test.tsx +++ b/src/components/track/TrackGridItem.test.tsx @@ -5,6 +5,7 @@ import TrackGridItem from './TrackGridItem'; import { KGAudioTrack } from '../../core/track/KGAudioTrack'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { createMockMidiRegion, createMockMidiTrack } from '../../test/utils/mock-data'; +import type { RegionPreviewContentStyle } from '../interfaces'; const storeState = { selectedRegionIds: [] as string[], @@ -56,6 +57,7 @@ vi.mock('../../core/audio-interface/KGAudioInterface', () => ({ describe('TrackGridItem preview behavior', () => { const getRegionItem = (regionId: string) => regionItemProps.get(regionId) as { style: React.CSSProperties; + previewContentStyle?: RegionPreviewContentStyle; onResizeStart?: (regionId: string, resizeAction: 'start' | 'end', initialX: number) => void; onResize?: (regionId: string, resizeAction: 'start' | 'end', deltaX: number) => void; onResizeEnd?: (regionId: string, resizeAction: 'start' | 'end') => void; @@ -145,6 +147,7 @@ describe('TrackGridItem preview behavior', () => { const SharedPreviewHarness = () => { const [previewRegionStyles, setPreviewRegionStyles] = useState>({}); + const [previewRegionContentStyles, setPreviewRegionContentStyles] = useState>({}); return ( <> @@ -153,6 +156,8 @@ describe('TrackGridItem preview behavior', () => { index={0} previewRegionStyles={previewRegionStyles} setPreviewRegionStyles={setPreviewRegionStyles} + previewRegionContentStyles={previewRegionContentStyles} + setPreviewRegionContentStyles={setPreviewRegionContentStyles} {...baseProps} /> { index={1} previewRegionStyles={previewRegionStyles} setPreviewRegionStyles={setPreviewRegionStyles} + previewRegionContentStyles={previewRegionContentStyles} + setPreviewRegionContentStyles={setPreviewRegionContentStyles} {...baseProps} /> @@ -205,11 +212,19 @@ describe('TrackGridItem preview behavior', () => { width: '140px', position: 'absolute', }); + expect(getRegionItem('region-a').previewContentStyle).toEqual({ + left: '0px', + width: '100px', + }); expect(getRegionItem('region-b').style).toEqual({ left: '200px', width: '240px', position: 'absolute', }); + expect(getRegionItem('region-b').previewContentStyle).toEqual({ + left: '0px', + width: '200px', + }); }); it('previews start resize for all selected regions across track rows', () => { @@ -231,11 +246,87 @@ describe('TrackGridItem preview behavior', () => { width: '160px', position: 'absolute', }); + expect(getRegionItem('region-a').previewContentStyle).toEqual({ + left: '-40px', + width: '200px', + }); expect(getRegionItem('region-b').style).toEqual({ left: '340px', width: '260px', position: 'absolute', }); + expect(getRegionItem('region-b').previewContentStyle).toEqual({ + left: '-40px', + width: '300px', + }); + }); + + it('keeps preview content fixed while shrinking from the end', () => { + renderSharedPreviewHarness( + ['region-a', 'region-b'], + [ + { id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 1, length: 2, name: 'Region A' }, + { id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 3, length: 3, name: 'Region B' }, + ], + ); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'end', 0); + getRegionItem('region-a').onResize?.('region-a', 'end', -40); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '0px', + width: '160px', + position: 'absolute', + }); + expect(getRegionItem('region-a').previewContentStyle).toEqual({ + left: '0px', + width: '200px', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '200px', + width: '260px', + position: 'absolute', + }); + expect(getRegionItem('region-b').previewContentStyle).toEqual({ + left: '0px', + width: '300px', + }); + }); + + it('shifts preview content right when extending from the start', () => { + renderSharedPreviewHarness( + ['region-a', 'region-b'], + [ + { id: 'region-a', trackId: '1', trackIndex: 0, barNumber: 2, length: 2, name: 'Region A' }, + { id: 'region-b', trackId: '2', trackIndex: 1, barNumber: 4, length: 3, name: 'Region B' }, + ], + ); + + act(() => { + getRegionItem('region-a').onResizeStart?.('region-a', 'start', 0); + getRegionItem('region-a').onResize?.('region-a', 'start', -40); + }); + + expect(getRegionItem('region-a').style).toEqual({ + left: '60px', + width: '240px', + position: 'absolute', + }); + expect(getRegionItem('region-a').previewContentStyle).toEqual({ + left: '40px', + width: '200px', + }); + expect(getRegionItem('region-b').style).toEqual({ + left: '260px', + width: '340px', + position: 'absolute', + }); + expect(getRegionItem('region-b').previewContentStyle).toEqual({ + left: '40px', + width: '300px', + }); }); it('previews drag movement for all selected regions across track rows', () => { @@ -320,11 +411,13 @@ describe('TrackGridItem preview behavior', () => { width: '100px', position: 'absolute', }); + expect(getRegionItem('region-a').previewContentStyle).toBeUndefined(); expect(getRegionItem('region-b').style).toEqual({ left: '200px', width: '200px', position: 'absolute', }); + expect(getRegionItem('region-b').previewContentStyle).toBeUndefined(); expect(onRegionResizeEnd).toHaveBeenCalledWith('region-a', 1, 1); act(() => { @@ -338,11 +431,13 @@ describe('TrackGridItem preview behavior', () => { width: '100px', position: 'absolute', }); + expect(getRegionItem('region-a').previewContentStyle).toBeUndefined(); expect(getRegionItem('region-b').style).toEqual({ left: '200px', width: '200px', position: 'absolute', }); + expect(getRegionItem('region-b').previewContentStyle).toBeUndefined(); expect(onRegionDragEnd).toHaveBeenCalledWith('region-a', 2, 0); }); }); diff --git a/src/components/track/TrackGridItem.tsx b/src/components/track/TrackGridItem.tsx index 8fd9889..0641618 100644 --- a/src/components/track/TrackGridItem.tsx +++ b/src/components/track/TrackGridItem.tsx @@ -5,7 +5,7 @@ import { KGAudioRegion } from '../../core/region/KGAudioRegion'; import { KGAudioInterface } from '../../core/audio-interface/KGAudioInterface'; import RegionItem from './RegionItem'; import TrackAutomationLane from './TrackAutomationLane'; -import type { RegionClickOptions, RegionUI, ResizeAction } from '../interfaces'; +import type { RegionClickOptions, RegionPreviewContentStyle, RegionUI, ResizeAction } from '../interfaces'; import { REGION_CONSTANTS, DEBUG_MODE } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; @@ -17,6 +17,7 @@ interface RegionResizePreviewBaseline { originalLength: number; originalLeft: number; originalWidth: number; + originalContentWidth: number; } interface RegionDragPreviewBaseline { @@ -53,6 +54,8 @@ interface TrackGridItemProps { onKGOneClipDrop?: (e: React.DragEvent, trackIndex: number) => void; previewRegionStyles?: Record; setPreviewRegionStyles?: React.Dispatch>>; + previewRegionContentStyles?: Record; + setPreviewRegionContentStyles?: React.Dispatch>>; } const TrackGridItem: React.FC = ({ @@ -81,6 +84,8 @@ const TrackGridItem: React.FC = ({ onKGOneClipDrop, previewRegionStyles, setPreviewRegionStyles, + previewRegionContentStyles, + setPreviewRegionContentStyles, }) => { const selectedRegionIds = useProjectStore(state => state.selectedRegionIds); const activeTrackAutomationTrackId = useProjectStore(state => state.activeTrackAutomationTrackId); @@ -97,6 +102,7 @@ const TrackGridItem: React.FC = ({ const [resizingRegion, setResizingRegion] = useState(null); const [draggingRegion, setDraggingRegion] = useState(null); const [localTempRegionStyles, setLocalTempRegionStyles] = useState>({}); + const [localPreviewRegionContentStyles, setLocalPreviewRegionContentStyles] = useState>({}); const [isModifierPressed, setIsModifierPressed] = useState(false); // Refs for resize operations @@ -120,6 +126,8 @@ const TrackGridItem: React.FC = ({ const isBulkRegionEdit = (regionId: string) => selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId); const tempRegionStyles = previewRegionStyles ?? localTempRegionStyles; const setTempRegionStyles = setPreviewRegionStyles ?? setLocalTempRegionStyles; + const tempPreviewRegionContentStyles = previewRegionContentStyles ?? localPreviewRegionContentStyles; + const setTempPreviewRegionContentStyles = setPreviewRegionContentStyles ?? setLocalPreviewRegionContentStyles; const getPreviewRegionIds = (regionId: string) => ( selectedRegionIds.length > 1 && selectedRegionIds.includes(regionId) @@ -142,6 +150,34 @@ const TrackGridItem: React.FC = ({ }); }; + const clearTempPreviewRegionContentStyles = (regionIds?: string[]) => { + if (!regionIds || regionIds.length === 0) { + setTempPreviewRegionContentStyles({}); + return; + } + + setTempPreviewRegionContentStyles(prev => { + const updated = { ...prev }; + regionIds.forEach(id => { + delete updated[id]; + }); + return updated; + }); + }; + + const getMeasuredRegionContentWidth = (regionId: string, fallbackWidth: number) => { + const regionElement = Array.from(document.querySelectorAll('[data-region-id]')) + .find(element => element.getAttribute('data-region-id') === regionId); + const regionContentElement = regionElement?.querySelector('.region-content'); + const measuredWidth = regionContentElement?.getBoundingClientRect().width; + + if (!measuredWidth || Number.isNaN(measuredWidth)) { + return fallbackWidth; + } + + return measuredWidth; + }; + // Update container width when the grid container changes size useEffect(() => { if (!gridContainerRef.current) return; @@ -253,6 +289,7 @@ const TrackGridItem: React.FC = ({ originalLength: candidate.length, originalLeft: (candidate.barNumber - 1) * barWidth, originalWidth: candidate.length * barWidth, + originalContentWidth: getMeasuredRegionContentWidth(candidate.id, candidate.length * barWidth), })); resizePreviewRegionIdsRef.current = resizePreviewBaselinesRef.current.map(baseline => baseline.regionId); @@ -267,6 +304,17 @@ const TrackGridItem: React.FC = ({ }, ])), })); + + setTempPreviewRegionContentStyles(prev => ({ + ...prev, + ...Object.fromEntries(resizePreviewBaselinesRef.current.map(baseline => [ + baseline.regionId, + { + left: '0px', + width: `${baseline.originalContentWidth}px`, + }, + ])), + })); }; // Handle region resize @@ -324,6 +372,7 @@ const TrackGridItem: React.FC = ({ originalLength: region.length, originalLeft, originalWidth, + originalContentWidth: getMeasuredRegionContentWidth(regionId, originalWidth), }]; setTempRegionStyles(prev => ({ @@ -337,6 +386,17 @@ const TrackGridItem: React.FC = ({ }, ])), })); + + setTempPreviewRegionContentStyles(prev => ({ + ...prev, + ...Object.fromEntries(previewBaselines.map(baseline => [ + baseline.regionId, + { + left: `${resizeAction === 'start' ? -(newLeft - originalLeft) : 0}px`, + width: `${baseline.originalContentWidth}px`, + }, + ])), + })); // Notify parent about resize if (onRegionResize) { @@ -401,6 +461,7 @@ const TrackGridItem: React.FC = ({ // Clear resizing state setResizingRegion(null); clearTempRegionStyles(resizePreviewRegionIdsRef.current); + clearTempPreviewRegionContentStyles(resizePreviewRegionIdsRef.current); currentResizeWidth.current = null; currentResizeLeft.current = null; currentResizeRegion.current = null; @@ -602,6 +663,7 @@ const TrackGridItem: React.FC = ({ // Clear dragging state setDraggingRegion(null); clearTempRegionStyles(dragPreviewRegionIdsRef.current); + clearTempPreviewRegionContentStyles(dragPreviewRegionIdsRef.current); currentDragLeft.current = null; currentDragTop.current = null; currentDragRegion.current = null; @@ -665,7 +727,7 @@ const TrackGridItem: React.FC = ({ }} onClick={(e) => { if (!isAutomationActive) { - onClick && onClick(e, index); + onClick?.(e, index); } }} ref={trackElementRef} @@ -733,6 +795,7 @@ const TrackGridItem: React.FC = ({ midiRegion={midiRegion} audioRegion={audioRegion} audioBuffer={audioBuffer} + previewContentStyle={tempPreviewRegionContentStyles[region.id]} /> ); })} diff --git a/src/components/track/TrackGridPanel.tsx b/src/components/track/TrackGridPanel.tsx index 7204b9a..87705c7 100644 --- a/src/components/track/TrackGridPanel.tsx +++ b/src/components/track/TrackGridPanel.tsx @@ -4,7 +4,7 @@ import { KGMidiRegion } from '../../core/region/KGMidiRegion'; import TrackGridItem from './TrackGridItem'; import { Playhead, FileImportModal } from '../common'; import SelectionBox from '../piano-roll/SelectionBox'; -import type { RegionClickOptions, RegionUI } from '../interfaces'; +import type { RegionClickOptions, RegionPreviewContentStyle, RegionUI } from '../interfaces'; import { DEBUG_MODE, PIANO_ROLL_CONSTANTS, REGION_CONSTANTS } from '../../constants'; import { KGMainContentState } from '../../core/state/KGMainContentState'; import { isModifierKeyPressed } from '../../util/osUtil'; @@ -67,6 +67,7 @@ const TrackGridPanel: React.FC = ({ const gridContainerRef = useRef(null); const [showAudioImportModal, setShowAudioImportModal] = useState(false); const [previewRegionStyles, setPreviewRegionStyles] = useState>({}); + const [previewRegionContentStyles, setPreviewRegionContentStyles] = useState>({}); const pendingAudioImportRef = useRef<{ barNumber: number; trackIndex: number } | null>(null); const isLassoSelectingRef = useRef(false); const isLassoShiftPressedRef = useRef(false); @@ -910,6 +911,8 @@ const TrackGridPanel: React.FC = ({ onKGOneClipDrop={handleExternalDrop} previewRegionStyles={previewRegionStyles} setPreviewRegionStyles={setPreviewRegionStyles} + previewRegionContentStyles={previewRegionContentStyles} + setPreviewRegionContentStyles={setPreviewRegionContentStyles} /> ))}