mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-02 01:05:20 +03:00
refactor: rewrote logic for building timeline to support stacking and changed to typescript
This commit is contained in:
parent
7401789b3b
commit
ddf7065379
@ -7,7 +7,7 @@ import { Play } from '../icons/Play';
|
||||
import { Previous } from '../icons/Previous';
|
||||
import { HistoryHeader } from '../routes/HistoryHeader';
|
||||
import { longToDate } from '../utils/dateUtil';
|
||||
import Timeline from './Timeline';
|
||||
import Timeline from './Timeline/Timeline';
|
||||
|
||||
const getLast24Hours = () => {
|
||||
return new Number(new Date(new Date().getTime() - 24 * 60 * 60 * 1000)) / 1000;
|
||||
@ -51,10 +51,7 @@ export default function HistoryViewer({ camera }) {
|
||||
|
||||
const handleTimelineChange = (timelineChangedEvent) => {
|
||||
if (timelineChangedEvent.seekComplete) {
|
||||
const currentEventExists = currentEvent !== undefined;
|
||||
if (!currentEventExists || currentEvent.id !== timelineChangedEvent.event.id) {
|
||||
setCurrentEvent(timelineChangedEvent.event);
|
||||
}
|
||||
setCurrentEvent(timelineChangedEvent.event);
|
||||
}
|
||||
|
||||
const videoContainer = videoRef.current;
|
||||
@ -143,7 +140,8 @@ export default function HistoryViewer({ camera }) {
|
||||
<div className='relative flex flex-col'>
|
||||
<HistoryHeader
|
||||
camera={camera}
|
||||
date={longToDate(currentEvent.start_time)}
|
||||
date={currentEvent.startTime}
|
||||
end={currentEvent.endTime}
|
||||
objectLabel={currentEvent.label}
|
||||
className='mb-2'
|
||||
/>
|
||||
@ -156,7 +154,7 @@ export default function HistoryViewer({ camera }) {
|
||||
events={timelineEvents}
|
||||
offset={timelineOffset}
|
||||
currentIndex={currentEventIndex}
|
||||
disabled={isPlaying}
|
||||
disableMarkerEvents={isPlaying}
|
||||
onChange={handleTimelineChange}
|
||||
/>
|
||||
|
||||
|
||||
@ -1,217 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { longToDate } from '../utils/dateUtil';
|
||||
|
||||
export default function Timeline({ events, offset, currentIndex, disabled, onChange }) {
|
||||
const timelineContainerRef = useRef(undefined);
|
||||
|
||||
const [timeline, setTimeline] = useState([]);
|
||||
const [timelineOffset, setTimelineOffset] = useState();
|
||||
const [markerTime, setMarkerTime] = useState();
|
||||
const [currentEvent, setCurrentEvent] = useState();
|
||||
const [scrollTimeout, setScrollTimeout] = useState();
|
||||
const [scrollActive, setScrollActive] = useState(true);
|
||||
const [eventsEnabled, setEventsEnable] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && timelineOffset) {
|
||||
const firstEvent = events[0];
|
||||
if (firstEvent) {
|
||||
setMarkerTime(longToDate(firstEvent.start_time));
|
||||
}
|
||||
|
||||
const firstEventTime = longToDate(firstEvent.start_time);
|
||||
const timelineEvents = events.map((e, i) => {
|
||||
const startTime = longToDate(e.start_time);
|
||||
const endTime = e.end_time ? longToDate(e.end_time) : new Date();
|
||||
const seconds = Math.round(Math.abs(endTime - startTime) / 1000);
|
||||
const positionX = Math.round(Math.abs(startTime - firstEventTime) / 1000 + timelineOffset);
|
||||
return {
|
||||
...e,
|
||||
startTime,
|
||||
endTime,
|
||||
seconds,
|
||||
width: seconds,
|
||||
positionX,
|
||||
};
|
||||
});
|
||||
|
||||
const firstTimelineEvent = timelineEvents[0];
|
||||
setCurrentEvent({
|
||||
...firstTimelineEvent,
|
||||
id: firstTimelineEvent.id,
|
||||
index: 0,
|
||||
startTime: longToDate(firstTimelineEvent.start_time),
|
||||
endTime: longToDate(firstTimelineEvent.end_time),
|
||||
});
|
||||
setTimeline(timelineEvents);
|
||||
}
|
||||
}, [events, timelineOffset]);
|
||||
|
||||
const getCurrentEvent = useCallback(() => {
|
||||
return currentEvent;
|
||||
}, [currentEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
const cEvent = getCurrentEvent();
|
||||
if (cEvent && offset >= 0) {
|
||||
timelineContainerRef.current.scroll({
|
||||
left: cEvent.positionX + offset - timelineOffset,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [offset, timelineContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeline.length > 0 && currentIndex !== undefined) {
|
||||
const event = timeline[currentIndex];
|
||||
setCurrentEvent({
|
||||
...event,
|
||||
id: event.id,
|
||||
index: currentIndex,
|
||||
startTime: longToDate(event.start_time),
|
||||
endTime: longToDate(event.end_time),
|
||||
});
|
||||
timelineContainerRef.current.scroll({ left: event.positionX - timelineOffset, behavior: 'smooth' });
|
||||
}
|
||||
}, [currentIndex, timelineContainerRef, timeline]);
|
||||
|
||||
const checkMarkerForEvent = (markerTime) => {
|
||||
if (!scrollActive) {
|
||||
setScrollActive(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeline) {
|
||||
const foundIndex = timeline.findIndex((event) => event.startTime <= markerTime && markerTime <= event.endTime);
|
||||
if (foundIndex > -1) {
|
||||
const found = timeline[foundIndex];
|
||||
if (found !== currentEvent && found.id !== currentEvent.id) {
|
||||
setCurrentEvent({
|
||||
...found,
|
||||
id: found.id,
|
||||
index: foundIndex,
|
||||
startTime: longToDate(found.start_time),
|
||||
endTime: longToDate(found.end_time),
|
||||
});
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scrollLogic = () => {
|
||||
clearTimeout(scrollTimeout);
|
||||
|
||||
const scrollPosition = timelineContainerRef.current.scrollLeft;
|
||||
const startTime = longToDate(timeline[0].start_time);
|
||||
const markerTime = new Date(startTime.getTime() + scrollPosition * 1000);
|
||||
setMarkerTime(markerTime);
|
||||
|
||||
handleChange(currentEvent, markerTime, false);
|
||||
|
||||
setScrollTimeout(
|
||||
setTimeout(() => {
|
||||
const foundEvent = checkMarkerForEvent(markerTime);
|
||||
handleChange(foundEvent ? foundEvent : currentEvent, markerTime, true);
|
||||
}, 250)
|
||||
);
|
||||
};
|
||||
|
||||
const handleWheel = (event) => {
|
||||
if (!disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollLogic(event);
|
||||
};
|
||||
|
||||
const handleScroll = (event) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
scrollLogic(event);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineContainerRef) {
|
||||
const timelineContainerWidth = timelineContainerRef.current.offsetWidth;
|
||||
const offset = Math.round(timelineContainerWidth / 2);
|
||||
setTimelineOffset(offset);
|
||||
}
|
||||
}, [timelineContainerRef]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event, time, seekComplete) => {
|
||||
if (onChange !== undefined) {
|
||||
onChange({
|
||||
event,
|
||||
time,
|
||||
seekComplete,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const RenderTimeline = useCallback(() => {
|
||||
if (timeline && timeline.length > 0) {
|
||||
const lastEvent = timeline[timeline.length - 1];
|
||||
const timelineLength = timelineOffset + lastEvent.positionX + lastEvent.width;
|
||||
return (
|
||||
<div
|
||||
className='relative flex items-center h-20'
|
||||
style={{
|
||||
width: `${timelineLength}px`,
|
||||
background: "url('/marker.png')",
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: '30px',
|
||||
backgroundRepeat: 'repeat-x',
|
||||
}}
|
||||
>
|
||||
{timeline.map((e) => {
|
||||
return (
|
||||
<div
|
||||
key={e.id}
|
||||
className='absolute z-10 rounded-full bg-blue-300 h-2'
|
||||
style={{
|
||||
left: `${e.positionX}px`,
|
||||
width: `${e.seconds}px`,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}, [timeline, timelineOffset]);
|
||||
|
||||
return (
|
||||
<div className='relative flex-grow-1'>
|
||||
<div className='absolute left-0 top-0 h-full w-full' style={{ textAlign: 'center' }}>
|
||||
<div className='h-full' style={{ margin: '0 auto', textAlign: 'center' }}>
|
||||
<span className='z-20 text-black dark:text-white'>
|
||||
{markerTime && <span>{markerTime.toLocaleTimeString()}</span>}
|
||||
</span>
|
||||
<div
|
||||
className='z-20 h-full absolute'
|
||||
style={{
|
||||
left: 'calc(100% / 2)',
|
||||
height: 'calc(100% - 24px)',
|
||||
borderRight: '2px solid rgba(252, 211, 77)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={timelineContainerRef}
|
||||
onWheel={handleWheel}
|
||||
onTouchMove={handleWheel}
|
||||
onScroll={handleScroll}
|
||||
className='overflow-x-auto hide-scroll'
|
||||
>
|
||||
<RenderTimeline />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
web/src/components/Timeline/Timeline.tsx
Normal file
235
web/src/components/Timeline/Timeline.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import { h } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { longToDate } from '../../utils/dateUtil';
|
||||
import { TimelineBlocks } from './TimelineBlocks';
|
||||
|
||||
export interface TimelineEvent {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface TimelineEventBlock extends TimelineEvent {
|
||||
index: number;
|
||||
yOffset: number;
|
||||
width: number;
|
||||
positionX: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
interface TimelineProps {
|
||||
events: TimelineEvent[];
|
||||
offset: number;
|
||||
disableMarkerEvents?: boolean;
|
||||
onChange: (timelineChangedEvent: any) => void;
|
||||
}
|
||||
|
||||
export default function Timeline({ events, offset, disableMarkerEvents, onChange }: TimelineProps) {
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(undefined);
|
||||
|
||||
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
|
||||
const [timelineOffset, setTimelineOffset] = useState<number | undefined>(undefined);
|
||||
const [markerTime, setMarkerTime] = useState<Date | undefined>(undefined);
|
||||
const [currentEvent, setCurrentEvent] = useState<TimelineEventBlock | undefined>(undefined);
|
||||
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
|
||||
const [isScrollAllowed, setScrollAllowed] = useState(!disableMarkerEvents);
|
||||
|
||||
useEffect(() => {
|
||||
setScrollAllowed(!disableMarkerEvents);
|
||||
}, [disableMarkerEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (offset > 0) {
|
||||
scrollToPositionInCurrentEvent(offset);
|
||||
}
|
||||
}, [offset]);
|
||||
|
||||
const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => {
|
||||
if (secondEvent.startTime < firstEvent.endTime) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const determineOffset = (currentEvent: TimelineEventBlock, previousEvents: TimelineEventBlock[]): number => {
|
||||
const OFFSET_DISTANCE_IN_PIXELS = 10;
|
||||
const previousIndex = previousEvents.length - 1;
|
||||
const previousEvent = previousEvents[previousIndex];
|
||||
if (previousEvent) {
|
||||
const isOverlap = checkEventForOverlap(previousEvent, currentEvent);
|
||||
if (isOverlap) {
|
||||
return OFFSET_DISTANCE_IN_PIXELS + determineOffset(currentEvent, previousEvents.slice(0, previousIndex));
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const buildTimelineView = (events: TimelineEvent[]): TimelineEventBlock[] => {
|
||||
const firstEvent = events[0];
|
||||
const firstEventTime = longToDate(firstEvent.start_time);
|
||||
return events
|
||||
.map((e, index) => {
|
||||
const startTime = longToDate(e.start_time);
|
||||
const endTime = e.end_time ? longToDate(e.end_time) : new Date();
|
||||
const seconds = Math.round(Math.abs(endTime.getTime() - startTime.getTime()) / 1000);
|
||||
const positionX = Math.round(Math.abs(startTime.getTime() - firstEventTime.getTime()) / 1000 + timelineOffset);
|
||||
return {
|
||||
...e,
|
||||
startTime,
|
||||
endTime,
|
||||
width: seconds,
|
||||
positionX,
|
||||
index,
|
||||
} as TimelineEventBlock;
|
||||
})
|
||||
.reduce((eventBlocks, current) => {
|
||||
const offset = determineOffset(current, eventBlocks);
|
||||
current.yOffset = offset;
|
||||
return [...eventBlocks, current];
|
||||
}, [] as TimelineEventBlock[]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && timelineOffset) {
|
||||
const timelineEvents = buildTimelineView(events);
|
||||
const lastEventIndex = timelineEvents.length - 1;
|
||||
|
||||
setTimeline(timelineEvents);
|
||||
setMarkerTime(timelineEvents[lastEventIndex].startTime);
|
||||
setCurrentEvent(timelineEvents[lastEventIndex]);
|
||||
}
|
||||
}, [events, timelineOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
const timelineIsLoaded = timeline.length > 0;
|
||||
if (timelineIsLoaded) {
|
||||
const lastEvent = timeline[timeline.length - 1];
|
||||
scrollToEvent(lastEvent);
|
||||
}
|
||||
}, [timeline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentEvent) {
|
||||
handleChange(true);
|
||||
}
|
||||
}, [currentEvent]);
|
||||
|
||||
const disableScrollEvent = (milliseconds) => {
|
||||
setScrollAllowed(false);
|
||||
let timeout: NodeJS.Timeout = undefined;
|
||||
timeout = setTimeout(() => {
|
||||
setScrollAllowed(true);
|
||||
clearTimeout(timeout);
|
||||
}, milliseconds);
|
||||
};
|
||||
|
||||
const scrollToPosition = (positionX: number) => {
|
||||
if (timelineContainerRef.current) {
|
||||
disableScrollEvent(150);
|
||||
timelineContainerRef.current.scroll({
|
||||
left: positionX,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToPositionInCurrentEvent = (offset: number) => {
|
||||
scrollToPosition(currentEvent.positionX + offset - timelineOffset);
|
||||
setMarkerTime(getCurrentMarkerTime());
|
||||
};
|
||||
|
||||
const scrollToEvent = (event, offset = 0) => {
|
||||
scrollToPosition(event.positionX + offset - timelineOffset);
|
||||
};
|
||||
|
||||
const checkMarkerForEvent = (markerTime) => {
|
||||
return [...timeline]
|
||||
.reverse()
|
||||
.find((timelineEvent) => timelineEvent.startTime <= markerTime && timelineEvent.endTime >= markerTime);
|
||||
};
|
||||
|
||||
const getCurrentMarkerTime = () => {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
const scrollPosition = timelineContainerRef.current.scrollLeft;
|
||||
const firstTimelineEvent = timeline[0] as TimelineEventBlock;
|
||||
const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime();
|
||||
return new Date(firstTimelineEventStartTime + scrollPosition * 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const onTimelineScrollHandler = () => {
|
||||
if (isScrollAllowed) {
|
||||
if (timelineContainerRef.current && timeline.length > 0) {
|
||||
clearTimeout(scrollTimeout);
|
||||
const currentMarkerTime = getCurrentMarkerTime();
|
||||
setMarkerTime(currentMarkerTime);
|
||||
handleChange(false);
|
||||
|
||||
setScrollTimeout(
|
||||
setTimeout(() => {
|
||||
const overlappingEvent = checkMarkerForEvent(currentMarkerTime);
|
||||
setCurrentEvent(overlappingEvent);
|
||||
}, 150)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineContainerRef) {
|
||||
const timelineContainerWidth = timelineContainerRef.current.offsetWidth;
|
||||
const offset = Math.round(timelineContainerWidth / 2);
|
||||
setTimelineOffset(offset);
|
||||
}
|
||||
}, [timelineContainerRef]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(seekComplete: boolean) => {
|
||||
if (onChange) {
|
||||
onChange({
|
||||
event: currentEvent,
|
||||
markerTime,
|
||||
seekComplete,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, currentEvent, markerTime]
|
||||
);
|
||||
|
||||
const handleViewEvent = (event: TimelineEventBlock) => {
|
||||
setCurrentEvent(event);
|
||||
setMarkerTime(getCurrentMarkerTime());
|
||||
scrollToEvent(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex-grow-1'>
|
||||
<div className='w-full text-center'>
|
||||
<span className='text-black dark:text-white'>
|
||||
{markerTime && <span>{markerTime.toLocaleTimeString()}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<div className='absolute left-0 top-0 h-full w-full text-center'>
|
||||
<div className='h-full text-center' style={{ margin: '0 auto' }}>
|
||||
<div
|
||||
className='z-20 h-full absolute'
|
||||
style={{
|
||||
left: 'calc(100% / 2)',
|
||||
borderRight: '2px solid rgba(252, 211, 77)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={timelineContainerRef} onScroll={onTimelineScrollHandler} className='overflow-x-auto hide-scroll'>
|
||||
{timeline.length > 0 && (
|
||||
<TimelineBlocks timeline={timeline} firstBlockOffset={timelineOffset} onEventClick={handleViewEvent} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
web/src/components/Timeline/TimelineBlockView.tsx
Normal file
24
web/src/components/Timeline/TimelineBlockView.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { h } from 'preact';
|
||||
import { TimelineEventBlock } from './Timeline';
|
||||
|
||||
interface TimelineBlockViewProps {
|
||||
block: TimelineEventBlock;
|
||||
onClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
|
||||
export const TimelineBlockView = ({ block, onClick }: TimelineBlockViewProps) => {
|
||||
const onClickHandler = () => onClick(block);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
onClick={onClickHandler}
|
||||
className='absolute z-10 rounded-full bg-blue-300 h-2'
|
||||
style={{
|
||||
top: `${block.yOffset}px`,
|
||||
left: `${block.positionX}px`,
|
||||
width: `${block.width}px`,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
39
web/src/components/Timeline/TimelineBlocks.tsx
Normal file
39
web/src/components/Timeline/TimelineBlocks.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { h } from 'preact';
|
||||
import { TimelineEventBlock } from './Timeline';
|
||||
import { TimelineBlockView } from './TimelineBlockView';
|
||||
|
||||
interface TimelineBlocksProps {
|
||||
timeline: TimelineEventBlock[];
|
||||
firstBlockOffset: number;
|
||||
onEventClick: (block: TimelineEventBlock) => void;
|
||||
}
|
||||
export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: TimelineBlocksProps) => {
|
||||
const calculateTimelineContainerWidth = () => {
|
||||
if (timeline.length > 0) {
|
||||
const startTimeEpoch = timeline[0].startTime.getTime();
|
||||
const endTimeEpoch = new Date().getTime();
|
||||
return Math.round(Math.abs(endTimeEpoch - startTimeEpoch) / 1000) + firstBlockOffset * 2;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickHandler = (block: TimelineEventBlock) => onEventClick(block);
|
||||
|
||||
if (timeline && timeline.length > 0) {
|
||||
return (
|
||||
<div
|
||||
className='relative block whitespace-nowrap overflow-x-hidden h-16'
|
||||
style={{
|
||||
width: `${calculateTimelineContainerWidth()}px`,
|
||||
background: "url('/marker.png')",
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: '30px',
|
||||
backgroundRepeat: 'repeat',
|
||||
}}
|
||||
>
|
||||
{timeline.map((block) => (
|
||||
<TimelineBlockView block={block} onClick={onClickHandler} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -1,13 +1,15 @@
|
||||
import { h } from 'preact'
|
||||
import Heading from '../components/Heading'
|
||||
import { h } from 'preact';
|
||||
import Heading from '../components/Heading';
|
||||
|
||||
export function HistoryHeader({ objectLabel, date, camera, className }) {
|
||||
export function HistoryHeader({ objectLabel, date, end, camera, className }) {
|
||||
return (
|
||||
<div className={`text-center ${className}`}>
|
||||
<Heading size="lg">{objectLabel}</Heading>
|
||||
<Heading size='lg'>{objectLabel}</Heading>
|
||||
<div>
|
||||
<span>Today, {date.toLocaleTimeString()} · {camera}</span>
|
||||
<span>
|
||||
Today, {date.toLocaleTimeString()} - {end ? end.toLocaleTimeString() : 'Incomplete'} · {camera}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxFactory": "h",
|
||||
"jsxFragmentFactory": "Fragment",
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user