Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// SPDX-License-Identifier: MIT

import React, {
useCallback, useEffect, useState,
useCallback, useEffect, useRef, useState,
} from 'react';
import Select from 'antd/lib/select';
import Collapse from 'antd/lib/collapse';
Expand All @@ -12,28 +12,39 @@ import Checkbox from 'antd/lib/checkbox';
import InputNumber from 'antd/lib/input-number';
import TextArea from 'antd/lib/input/TextArea';
import Popover from 'antd/lib/popover';
import Tooltip from 'antd/lib/tooltip';
import { EditOutlined } from '@ant-design/icons';

import { AudioIntervalState, Label, Attribute } from 'cvat-core-wrapper';
import { ActiveControl } from 'reducers';
import { clamp } from 'utils/math';
import { formatTimeShort, formatMilliseconds } from 'audio/utils/format-audio-time';
import { type TextareaFocusBookmark, useTextareaFocusBookmark } from 'audio/hooks/use-textarea-focus-bookmark';

interface AudioRegionDetailsProps {
interval: AudioIntervalState;
intervalIndex: number;
labels: Label[];
activeControl: ActiveControl;
trackDurationSeconds: number;
onChangeLabel(labelId: number): void;
onChangeAttribute(attrID: number, value: string): void;
}

function canRestoreOutsideOverlay(event: KeyboardEvent): boolean {
return !(event.target instanceof Element && event.target.closest(
'.ant-modal, .ant-popover, .ant-dropdown, .ant-select-dropdown',
));
}

function TextAttributeInput({
attributeID, value, disabled, onChange,
attributeID, value, disabled, onChange, textareaFocusBookmark,
}: {
attributeID: number;
value: string;
disabled: boolean;
onChange(attrID: number, value: string): void;
textareaFocusBookmark: TextareaFocusBookmark | null;
}): JSX.Element {
// Using local value prevents the value to be replaced in the text area on every keystroke
// It helps keeping the caret position as well as working system shortcuts like undo/redo
Expand All @@ -54,26 +65,44 @@ function TextAttributeInput({
}
}, [localValue]);

const hasFocusBookmark = textareaFocusBookmark?.element.getAttribute('data-cvat-attribute-id') === String(attributeID);

return (
<TextArea
rows={4}
size='small'
value={localValue}
disabled={disabled}
onChange={(event) => {
setLocalValue(event.target.value);
}}
/>
<div className='cvat-audio-region-textarea'>
<TextArea
rows={4}
size='small'
value={localValue}
disabled={disabled}
data-cvat-attribute-id={attributeID}
onChange={(event) => {
setLocalValue(event.target.value);
}}
/>
{hasFocusBookmark ? (
<Tooltip title='Shortcuts are active. Press Esc in Cursor mode to resume editing here.'>
<span
className='cvat-audio-region-textarea-bookmark-caret'
style={{
left: textareaFocusBookmark.marker.left,
top: textareaFocusBookmark.marker.top,
height: textareaFocusBookmark.marker.height,
}}
/>
</Tooltip>
) : null}
</div>
);
}

function AttributeInput({
attribute, value, disabled, onChange,
attribute, value, disabled, onChange, textareaFocusBookmark,
}: {
attribute: Attribute;
value: string;
disabled: boolean;
onChange(attrID: number, val: string): void;
textareaFocusBookmark: TextareaFocusBookmark | null;
}): JSX.Element {
if (attribute.inputType === 'checkbox') {
return (
Expand Down Expand Up @@ -144,6 +173,7 @@ function AttributeInput({
value={value}
disabled={disabled}
onChange={onChange}
textareaFocusBookmark={textareaFocusBookmark}
/>
);
}
Expand Down Expand Up @@ -225,6 +255,7 @@ function AudioRegionDetails(props: AudioRegionDetailsProps): JSX.Element {
interval,
intervalIndex,
labels,
activeControl,
trackDurationSeconds,
onChangeLabel,
onChangeAttribute,
Expand All @@ -234,6 +265,7 @@ function AudioRegionDetails(props: AudioRegionDetailsProps): JSX.Element {
labels.find((l) => l.id === interval.label.id) : null;

const isReadonly = !!interval.lock;
const bookmarkScope = `${interval.clientID}:${interval.label.id}:${isReadonly}`;
const startMs = interval.start;
const endMs = interval.stop ?? (trackDurationSeconds ? trackDurationSeconds * 1000 : interval.start);
const durationMs = Math.max(0, endMs - startMs);
Expand All @@ -247,6 +279,26 @@ function AudioRegionDetails(props: AudioRegionDetailsProps): JSX.Element {
const attributes: Attribute[] = activeLabel?.attributes ?? [];

const [expandedByRegion, setExpandedByRegion] = useState<Record<string, string[]>>({});
const detailsRef = useRef<HTMLDivElement>(null);
const canRestoreTextareaFocusBookmark = useCallback((event: KeyboardEvent) => (
activeControl === ActiveControl.CURSOR && canRestoreOutsideOverlay(event)
), [activeControl]);
const { bookmark: textareaFocusBookmark } = useTextareaFocusBookmark(
detailsRef,
canRestoreTextareaFocusBookmark,
bookmarkScope,
);

const handleEscape = useCallback((event: React.KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Escape' && !event.nativeEvent.isComposing) {
const { activeElement } = window.document;
if (activeElement instanceof HTMLElement && detailsRef.current?.contains(activeElement)) {
event.preventDefault();
event.stopPropagation();
activeElement.blur();
}
}
}, []);
const expandedKey = String(interval.clientID);
const attributeKeys = attributes.map((attribute) => `attr-${attribute.id}`);
const expandedKeys = expandedByRegion[expandedKey] ?? attributeKeys;
Expand All @@ -257,7 +309,7 @@ function AudioRegionDetails(props: AudioRegionDetailsProps): JSX.Element {
}, [expandedKey]);

return (
<div className='cvat-audio-region-details'>
<div ref={detailsRef} className='cvat-audio-region-details' onKeyDownCapture={handleEscape}>
<div className='cvat-audio-region-details-header'>
<span className='cvat-audio-region-details-index'>
{intervalIndex + 1}
Expand Down Expand Up @@ -307,6 +359,7 @@ function AudioRegionDetails(props: AudioRegionDetailsProps): JSX.Element {
value={interval.attributes[attribute.id!] ?? attribute.defaultValue}
disabled={isReadonly}
onChange={handleChangeAttribute}
textareaFocusBookmark={textareaFocusBookmark}
/>
),
}))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,31 @@
margin-left: 8px;
}

.cvat-audio-region-textarea {
position: relative;

.cvat-audio-region-textarea-bookmark-caret {
position: absolute;
width: 2px;
min-height: 1em;
background-color: $player-slider-color;
border-radius: 1px;
pointer-events: auto;
z-index: 1;

&::after {
content: '';
position: absolute;
top: -3px;
left: -2px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: $player-slider-color;
}
}
}

.cvat-audio-region-no-attributes {
padding: 16px;
text-align: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ import { shallowEqual, ThunkDispatch } from 'utils/redux';
function AudioRegionDetailsWrapper(): JSX.Element | null {
const dispatch = useDispatch<ThunkDispatch>();
const {
intervals, activeIntervalID, labels, duration,
intervals, activeIntervalID, labels, duration, activeControl,
} = useSelector((state: CombinedState) => ({
intervals: state.audio.player.intervals,
activeIntervalID: state.audio.player.activeIntervalID,
labels: state.annotation.job.labels,
duration: state.audio.player.duration,
activeControl: state.annotation.canvas.activeControl,
}), shallowEqual);
const interval = activeIntervalID === null ? null :
intervals.find((item) => item.clientID === activeIntervalID);
Expand All @@ -43,6 +44,7 @@ function AudioRegionDetailsWrapper(): JSX.Element | null {
interval={interval}
intervalIndex={intervals.indexOf(interval)}
labels={labels}
activeControl={activeControl}
trackDurationSeconds={duration}
onChangeLabel={handleChangeLabel}
onChangeAttribute={handleChangeAttribute}
Expand Down
132 changes: 132 additions & 0 deletions cvat-ui/src/audio/hooks/use-textarea-focus-bookmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (C) CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT

import {
RefObject, useCallback, useEffect, useRef, useState,
} from 'react';

export interface TextareaFocusBookmark {
element: HTMLTextAreaElement;
caretOffset: number;
marker: {
left: number;
top: number;
height: number;
};
}

// Textareas do not expose a DOM range for their caret, so this mirror is needed
// to place the visual marker at the saved text offset.
function getCaretMarkerPosition(textarea: HTMLTextAreaElement, caretOffset: number): TextareaFocusBookmark['marker'] {
const styles = window.getComputedStyle(textarea);
const mirror = window.document.createElement('div');
const marker = window.document.createElement('span');

Object.assign(mirror.style, {
position: 'absolute',
visibility: 'hidden',
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
top: '0',
left: '-9999px',
boxSizing: styles.boxSizing,
width: styles.width,
padding: styles.padding,
border: styles.border,
font: styles.font,
lineHeight: styles.lineHeight,
});

mirror.textContent = textarea.value.slice(0, caretOffset);
marker.textContent = '\u200b';
mirror.append(marker);
window.document.body.append(mirror);

const position = {
left: marker.offsetLeft - textarea.scrollLeft,
top: marker.offsetTop - textarea.scrollTop,
height: Number.parseFloat(styles.lineHeight) || Number.parseFloat(styles.fontSize),
};

mirror.remove();
return position;
}

function isEditableTarget(target: EventTarget | null): boolean {
return target instanceof Element && !!target.closest('input, select, textarea, [contenteditable="true"]');
}

export function useTextareaFocusBookmark(
containerRef: RefObject<HTMLElement>,
canRestore: (event: KeyboardEvent) => boolean,
bookmarkScope: string,
): {
bookmark: TextareaFocusBookmark | null;
} {
const [bookmark, setBookmark] = useState<TextareaFocusBookmark | null>(null);
const bookmarkRef = useRef<TextareaFocusBookmark | null>(null);
const canRestoreRef = useRef(canRestore);
canRestoreRef.current = canRestore;

const clearBookmark = useCallback(() => {
bookmarkRef.current = null;
setBookmark(null);
}, []);

useEffect(() => {
clearBookmark();
}, [bookmarkScope, clearBookmark]);

useEffect(() => {
const handleFocusIn = (event: FocusEvent): void => {
if (event.target instanceof Element && containerRef.current?.contains(event.target)) {
clearBookmark();
}
};

const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape' || event.isComposing) {
return;
}

const savedBookmark = bookmarkRef.current;
if (event.target instanceof HTMLTextAreaElement && containerRef.current?.contains(event.target)) {
const caretOffset = event.target.selectionEnd;

event.preventDefault();
event.stopPropagation();
const nextBookmark: TextareaFocusBookmark = {
element: event.target,
caretOffset,
marker: getCaretMarkerPosition(event.target, caretOffset),
};
bookmarkRef.current = nextBookmark;
setBookmark(nextBookmark);
event.target.blur();
return;
}

if (
savedBookmark &&
canRestoreRef.current(event) &&
!isEditableTarget(event.target)
) {
event.preventDefault();
event.stopPropagation();
clearBookmark();
savedBookmark.element.focus({ preventScroll: true });
savedBookmark.element.setSelectionRange(savedBookmark.caretOffset, savedBookmark.caretOffset);
}
};

window.document.addEventListener('focusin', handleFocusIn, true);
window.document.addEventListener('keydown', handleKeyDown, true);
return () => {
window.document.removeEventListener('focusin', handleFocusIn, true);
window.document.removeEventListener('keydown', handleKeyDown, true);
};
}, [clearBookmark, containerRef]);

return { bookmark };
}
Loading