From abee88e940bc038acf539911b494ce86d26912c3 Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Wed, 4 Feb 2026 13:54:51 +0100 Subject: [PATCH 01/14] [Upd] rect tool + polygon tool + redo/undo --- README.md | 24 +++- index.html | 25 ++-- public/favicon.svg | 11 ++ src/features/annotation/AnnotationsLayer.tsx | 22 ++-- .../annotation/PolylineAnnotation.tsx | 44 +++++-- .../annotation/SelectableAnnotation.tsx | 3 - .../annotation/TextAnnotationShape.tsx | 6 +- .../context/editor/EditorContext.types.ts | 1 + .../context/editor/EditorProvider.tsx | 84 ++++++++---- src/features/mediaStage/MediaStage.tsx | 6 +- src/features/mediaStage/StageSurface.tsx | 9 +- .../mediaStage/asset/MediaAssetContainer.tsx | 16 ++- .../mediaStage/asset/video/VideoAsset.tsx | 29 +++-- .../video/videoControls/VideoControls.tsx | 105 +++++++++------ src/features/toolbox/ToolBox.tsx | 3 +- .../toolbox/commands/StateCommandManager.ts | 31 +++++ .../AddAnnotationStateCommand.ts | 34 +++++ .../RemoveAnnotationStateCommand.ts | 34 +++++ .../UpdateAnnotationStateCommand.ts | 28 ++++ .../styleControls/BaseStyleControls.tsx | 123 +++++++++++------- .../toolbox/styleControls/ColorPicker.tsx | 42 ++++++ .../styleControls/CustomTextControls.tsx | 9 +- .../styleControls/PolylineStyleControls.tsx | 18 ++- .../toolbox/styleControls/StyleControls.tsx | 11 +- .../toolbox/toolsContext/FreehandDrawTool.ts | 1 + .../toolbox/toolsContext/PolygonDrawTool.ts | 117 +++++++++++++++++ .../toolbox/toolsContext/RectDrawTool.ts | 58 +++++++++ .../toolbox/toolsContext/TextDrawTool.ts | 5 +- .../toolsContext/ToolContextInterface.ts | 1 + .../toolbox/toolsContext/ToolController.ts | 34 ++--- .../toolbox/toolsContext/ToolRegistry.ts | 4 + src/router/AppRouter.tsx | 19 +-- src/types/intern/stateCommand.ts | 4 + src/utils/Constants.ts | 65 ++++++--- 34 files changed, 789 insertions(+), 237 deletions(-) create mode 100644 public/favicon.svg create mode 100644 src/features/toolbox/commands/StateCommandManager.ts create mode 100644 src/features/toolbox/commands/stateCommands/AddAnnotationStateCommand.ts create mode 100644 src/features/toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts create mode 100644 src/features/toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts create mode 100644 src/features/toolbox/styleControls/ColorPicker.tsx create mode 100644 src/features/toolbox/toolsContext/PolygonDrawTool.ts create mode 100644 src/features/toolbox/toolsContext/RectDrawTool.ts create mode 100644 src/types/intern/stateCommand.ts diff --git a/README.md b/README.md index 1e9630f..e869427 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,16 @@ and is designed to work with the **Media Asset Annotator Service** backend. ## [![Vercel Deploy](https://deploy-badge.vercel.app/vercel/media-asset-annotator?style=for-the-badge&name=vercel+demo)](https://media-asset-annotator.vercel.app) +--- +### Application Entry + +The application uses **client-side routing**. +A **default route (`/`)** is defined and automatically redirects to a demo annotator view. + +- `/` → redirects to a predefined demo media asset +- `/annotator?id=` → loads a media asset from the backend +- `/annotator?url=` → loads an external media asset by URL +--- ## Technology Stack ### Core Framework @@ -37,6 +47,7 @@ This project uses Prettier for code formatting and ESLint for linting to maintai ### Prerequisites - Node.js (v24+) +- Configuration via `.env` file in root directory (refer to `.env.example` for guidance) ### Install Dependencies @@ -55,8 +66,10 @@ Start the development server: ```bash npm run dev ``` - -Open your browser and navigate to `http://localhost:5173/annotator?id=1 to access the application. +Access the application using one of the following URLs: +- http://localhost:5173/ (default demo) +- http://localhost:5173/annotator?id= +- http://localhost:5173/annotator?url= ### Build for Production @@ -75,6 +88,7 @@ To preview the production build locally, run: ```bash npm run preview ``` - -This will start a local server to serve the files from the `dist` directory. -Navigate to `http://localhost:4173/annotator?id=` or `https://localhost:4173/annotator?url=` to view the production build. +Access the application using one of the following URLs: +- http://localhost:4173/ (default demo) +- http://localhost:4173/annotator?id= +- http://localhost:4173/annotator?url= \ No newline at end of file diff --git a/index.html b/index.html index 42d6fa0..ca9fc8b 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,15 @@ - + - - - - Media Asset Annotator - - -
- - - + + + + + + + Media Asset Annotator + + +
+ + + \ No newline at end of file diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6db0f21 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/features/annotation/AnnotationsLayer.tsx b/src/features/annotation/AnnotationsLayer.tsx index e51b073..26814f1 100644 --- a/src/features/annotation/AnnotationsLayer.tsx +++ b/src/features/annotation/AnnotationsLayer.tsx @@ -10,7 +10,8 @@ interface Props { sceneWidth: number; sceneHeight: number; currentTime: number; - onUpdate: (updated: Annotation) => void; + isActive: boolean | undefined; + onCommit: (before: Annotation, after: Annotation) => void; isEditing: boolean; selectedId?: string | null; onSelect: (annotationId: string) => void; @@ -20,22 +21,25 @@ export const AnnotationsLayer = ({ annotations, mediaType, currentTime, - onUpdate, + onCommit, isEditing, + isActive, selectedId, onSelect, }: Props) => { - // @ts-ignore - // TODO preview with annotations - const visible = annotations.filter((a) => { + + const visible = annotations.filter((a) => { if (mediaType === Constants.IMAGE_ASSET_TYPE_LABEL) return true; if (a.time.start == null && a.time.end == null) return true; - return a.time.start >= currentTime && a.time.end >= currentTime; + return a.time.end >= currentTime; }); + const current = isActive ? visible : annotations; + + return ( <> - {annotations.map((a) => { + {current.map((a) => { if (a.kind === 'polyline') { return ( onSelect(a.id)} - onUpdate={onUpdate} + onCommit={onCommit} /> ); } @@ -56,7 +60,7 @@ export const AnnotationsLayer = ({ isSelected={a.id === selectedId} onSelect={() => onSelect(a.id)} key={a.id} - onUpdate={onUpdate} + onCommit={onCommit} /> ); } diff --git a/src/features/annotation/PolylineAnnotation.tsx b/src/features/annotation/PolylineAnnotation.tsx index 41ff4c4..9569088 100644 --- a/src/features/annotation/PolylineAnnotation.tsx +++ b/src/features/annotation/PolylineAnnotation.tsx @@ -7,7 +7,7 @@ import { useRef } from 'react'; interface PolylineAnnotationProps { annotation: PolylineAnnotation; isEditing: boolean; - onUpdate: (updated: Annotation) => void; + onCommit: (before: Annotation, after: Annotation) => void; isSelected: boolean; onSelect: () => void; } @@ -15,10 +15,11 @@ interface PolylineAnnotationProps { const PolylineAnnotationShape = ({ annotation, isEditing, - onUpdate, + onCommit, isSelected, onSelect, }: PolylineAnnotationProps) => { + const lineRef = useRef(null); const handleDragEnd = (e: Konva.KonvaEventObject) => { const node = e.target as Konva.Line; @@ -30,21 +31,44 @@ const PolylineAnnotationShape = ({ const newPoints = annotation.points.map((v, i) => (i % 2 === 0 ? v + dx : v + dy)); - onUpdate({ ...annotation, points: newPoints }); + onCommit(annotation, { ...annotation, points: newPoints }); }; const handleTransformEnd = (node: Konva.Node) => { - if (!node) return; + const line = node as Konva.Line; + const stage = line.getStage(); + if (!stage) return; + + const stageScaleX = stage.scaleX(); + const stageScaleY = stage.scaleY(); + + const localPoints = line.points(); + const absTransform = line.getAbsoluteTransform(); - const scaleX = node.scaleX(); - const scaleY = node.scaleY(); + const newPoints: number[] = []; - const newPoints = annotation.points.map((v, i) => (i % 2 === 0 ? v * scaleX : v * scaleY)); + for (let i = 0; i < localPoints.length; i += 2) { + const p = absTransform.point({ + x: localPoints[i], + y: localPoints[i + 1], + }); - node.scaleX(1); - node.scaleY(1); + // REMOVE konva internal stage scale before storing + newPoints.push( + p.x / stageScaleX, + p.y / stageScaleY + ); + } + + line.setAttrs({ + x: 0, + y: 0, + scaleX: 1, + scaleY: 1, + }); - onUpdate({ + onCommit(annotation, + { ...annotation, points: newPoints, }); diff --git a/src/features/annotation/SelectableAnnotation.tsx b/src/features/annotation/SelectableAnnotation.tsx index 49950d0..93409c2 100644 --- a/src/features/annotation/SelectableAnnotation.tsx +++ b/src/features/annotation/SelectableAnnotation.tsx @@ -31,7 +31,6 @@ const SelectableAnnotation = ({ } }, [isSelected, nodeRef]); - /* ---------- resize limit ---------- */ const boundBoxFunc = useCallback((oldBox: Box, newBox: Box) => { const stage = transformerRef.current?.getStage(); if (!stage) return oldBox; @@ -45,7 +44,6 @@ const SelectableAnnotation = ({ return outOfBounds ? oldBox : newBox; }, []); - /* ---------- transformer drag limit ---------- */ const handleTransformerDrag = useCallback( (e: Konva.KonvaEventObject) => { const tr = transformerRef.current; @@ -108,7 +106,6 @@ const SelectableAnnotation = ({ }; }, [isSelected, isTransformable, nodeRef]); - /* ---------- transform commit ---------- */ const handleTransformEnd = useCallback(() => { const node = nodeRef.current; if (node) diff --git a/src/features/annotation/TextAnnotationShape.tsx b/src/features/annotation/TextAnnotationShape.tsx index 4e6a890..a421bff 100644 --- a/src/features/annotation/TextAnnotationShape.tsx +++ b/src/features/annotation/TextAnnotationShape.tsx @@ -8,7 +8,7 @@ import { Constants } from '../../utils/Constants.ts'; interface EditableTextProps { annotation: TextAnnotation; isEditing: boolean; - onUpdate: (annotation: Annotation) => void; + onCommit: (before: Annotation, after: Annotation) => void; isSelected: boolean; onSelect: () => void; } @@ -16,7 +16,7 @@ interface EditableTextProps { const EditableText = ({ annotation, isEditing, - onUpdate, + onCommit, isSelected, onSelect, }: EditableTextProps) => { @@ -26,7 +26,7 @@ const EditableText = ({ const node = e.target; const x = node.x(); const y = node.y(); - onUpdate({ + onCommit(annotation, { ...annotation, x, y, diff --git a/src/features/context/editor/EditorContext.types.ts b/src/features/context/editor/EditorContext.types.ts index f33c303..6132015 100644 --- a/src/features/context/editor/EditorContext.types.ts +++ b/src/features/context/editor/EditorContext.types.ts @@ -27,6 +27,7 @@ export interface EditorState extends EditorMutators { export interface EditorMutators { addAnnotation: (a: Annotation) => void; updateAnnotation: (id: string, patch: AnnotationPatch) => void; + commitAnnotation: (before: Annotation, after: Annotation) => void; removeAnnotation: (id: string) => void; selectAnnotation: (id: string | null) => void; } diff --git a/src/features/context/editor/EditorProvider.tsx b/src/features/context/editor/EditorProvider.tsx index 552b9f2..e219f56 100644 --- a/src/features/context/editor/EditorProvider.tsx +++ b/src/features/context/editor/EditorProvider.tsx @@ -16,6 +16,10 @@ import { } from '../../../types/mapper/annotationMapper.ts'; import { fetchAnnotations } from '../../../api/fetchAnnotations.ts'; import type { Tool } from '../../toolbox/tools/tools.items.tsx'; +import {StateCommandManager} from "../../toolbox/commands/StateCommandManager.ts"; +import {UpdateAnnotationCommand} from "../../toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts"; +import {AddAnnotationCommand} from "../../toolbox/commands/stateCommands/AddAnnotationStateCommand.ts"; +import {RemoveAnnotationCommand} from "../../toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts"; export const EditorProvider = ({ children }: { children: React.ReactNode }) => { const [selectedId, setSelectedId] = useState(null); @@ -26,26 +30,59 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { const { cursor, duration } = usePlayback(); const addAnnotation = (a: Annotation) => { - setAnnotations((prev) => [...prev, a]); + stateCommandsRef.current!.execute( + new AddAnnotationCommand( + a, + (a) => { + setAnnotations((prev) => [...prev, a]); + }, + (id) => { + setAnnotations((prev) => prev.filter((ann) => ann.id !== id)); + setSelectedId((prev) => (prev === id ? null : prev)); + } + ), + ); + }; + + const commitUpdateAnnotation = (before: Annotation, after: Annotation) => { + if (!before) return + stateCommandsRef.current!.execute( + new UpdateAnnotationCommand( + before, + after, + updateAnnotation, + ), + ); }; const updateAnnotation = (id: string, patch: AnnotationPatch) => { setAnnotations((prev) => - prev.map((a) => - a.id === id - ? { - ...a, - ...patch, - style: patch.style ? { ...a.style, ...patch.style } : a.style, - } - : a, - ), + prev.map((a) => + a.id === id + ? { + ...a, + ...patch, + style: patch.style ? { ...a.style, ...patch.style } : a.style, + } + : a, + ), ); - }; + } const removeAnnotation = (id: string) => { - setAnnotations((prev) => prev.filter((a) => a.id !== id)); - setSelectedId((prev) => (prev === id ? null : prev)); + stateCommandsRef.current!.execute( + new RemoveAnnotationCommand( + annotations.find((a) => a.id === id)!, + (a) => { + setAnnotations((prev) => [...prev, a]); + setSelectedId(a.id); + }, + (id) => { + setAnnotations((prev) => prev.filter((ann) => ann.id !== id)); + setSelectedId((prev) => (prev === id ? null : prev)); + } + ), + ); }; const removeSelected = () => { @@ -65,11 +102,11 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { }; const undo = () => { - console.warn('Undo not implemented yet'); + stateCommandsRef.current!.undo(); }; const redo = () => { - console.warn('Redo not implemented yet'); + stateCommandsRef.current!.redo(); }; const setEditing = (v: boolean) => { @@ -77,13 +114,11 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { if (!v) setSelectedId(null); }; - /* ---------------- tool controller ---------------- */ const toolControllerRef = useRef(null); + const stateCommandsRef = useRef(null); const toolRegistryRef = useRef(null); - // initialize once - useEffect(() => { if (!asset) return; @@ -117,15 +152,15 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { { addAnnotation, updateAnnotation, + commitAnnotation: commitUpdateAnnotation, removeAnnotation, selectAnnotation, }, - () => { - setActiveTool(Constants.SELECT_TOOL_LABEL as Tool); - }, + setActiveTool, ); toolRegistryRef.current = new ToolRegistry(toolControllerRef.current); + stateCommandsRef.current = new StateCommandManager(); return () => { toolControllerRef.current?.onEditingDisabled(); @@ -138,7 +173,7 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { if (!toolControllerRef.current || !toolRegistryRef.current) return; const tool = toolRegistryRef.current.get(activeTool); - toolControllerRef.current.setTool(tool); + toolControllerRef.current.setToolStrategy(tool); }, [activeTool]); useEffect(() => { @@ -156,8 +191,6 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { const getToolController = () => toolControllerRef.current!; - /* ---------------- context value ---------------- */ - const value: EditorState = { annotations, selectedId, @@ -172,13 +205,14 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { addAnnotation, updateAnnotation, + commitAnnotation: commitUpdateAnnotation, removeAnnotation, removeSelected, save, undo, redo, - setAnnotations, // intentional escape hatch + setAnnotations, }; return {children}; diff --git a/src/features/mediaStage/MediaStage.tsx b/src/features/mediaStage/MediaStage.tsx index a3d627a..ee53907 100644 --- a/src/features/mediaStage/MediaStage.tsx +++ b/src/features/mediaStage/MediaStage.tsx @@ -10,11 +10,12 @@ export const MediaStage = () => { selectedId, isEditing, updateAnnotation, + commitAnnotation, selectAnnotation, getToolController, } = useEditor(); - const { cursor } = usePlayback(); + const { cursor, isActive, setActive } = usePlayback(); if (!asset) return null; @@ -22,12 +23,15 @@ export const MediaStage = () => { updateAnnotation(a.id, a)} + onCommitAnnotation={commitAnnotation} onSelectAnnotation={selectAnnotation} toolController={getToolController()} /> diff --git a/src/features/mediaStage/StageSurface.tsx b/src/features/mediaStage/StageSurface.tsx index fd817bf..e719efb 100644 --- a/src/features/mediaStage/StageSurface.tsx +++ b/src/features/mediaStage/StageSurface.tsx @@ -12,12 +12,13 @@ interface StageSurfaceProps { annotations: Annotation[]; selectedId: string | null; isEditing: boolean; + isActive?: boolean; currentTime: number; mediaType: 'image' | 'video'; onSelect: (id: string | null) => void; onUpdate: (a: Annotation) => void; - + onCommit: (b: Annotation, a: Annotation) => void; onPointerDown: (p: Point) => void; onPointerMove: (p: Point) => void; onPointerUp: (p: Point) => void; @@ -50,7 +51,8 @@ export const StageSurface = ({ currentTime, mediaType, onSelect, - onUpdate, + isActive, + onCommit, onPointerDown, onPointerMove, onPointerUp, @@ -82,12 +84,13 @@ export const StageSurface = ({ annotations={annotations} selectedId={selectedId} isEditing={isEditing} + isActive={isActive} mediaType={mediaType} currentTime={currentTime} sceneWidth={width} sceneHeight={height} onSelect={onSelect} - onUpdate={onUpdate} + onCommit={onCommit} /> diff --git a/src/features/mediaStage/asset/MediaAssetContainer.tsx b/src/features/mediaStage/asset/MediaAssetContainer.tsx index f30c231..5a98860 100644 --- a/src/features/mediaStage/asset/MediaAssetContainer.tsx +++ b/src/features/mediaStage/asset/MediaAssetContainer.tsx @@ -2,7 +2,7 @@ import { StageSurface } from '../StageSurface.tsx'; import { Constants } from '../../../utils/Constants.ts'; import VideoAsset from './video/VideoAsset.tsx'; import type { ToolController } from '../../toolbox/toolsContext/ToolController.ts'; -import type { Annotation } from '../../../types/intern/annotation.ts'; +import type {Annotation} from '../../../types/intern/annotation.ts'; import type { MediaAsset, MediaLayout } from '../../../types/intern/media.ts'; import ImageAsset from './image/ImageAsset.tsx'; @@ -13,9 +13,12 @@ interface MediaAssetContainerProps { annotations: Annotation[]; selectedId: string | null; isEditing: boolean; + isActive?: boolean; + setActive?: (active: boolean) => void; currentTime: number; toolController: ToolController; - onUpdateAnnotation: (a: Annotation) => void; + onUpdateAnnotation: (annotation: Annotation) => void; + onCommitAnnotation: (before: Annotation, after: Annotation) => void; onSelectAnnotation: (id: string | null) => void; } @@ -27,16 +30,21 @@ export const MediaAssetContainer = (props: MediaAssetContainerProps) => { annotations, selectedId, isEditing, + isActive, + setActive, currentTime, toolController, onUpdateAnnotation, + onCommitAnnotation, onSelectAnnotation, + } = props; const surface = (size: { w: number; h: number; scaleX: number; scaleY: number }) => ( { mediaType={asset.type} onSelect={onSelectAnnotation} onUpdate={onUpdateAnnotation} + onCommit={onCommitAnnotation} onPointerDown={(p) => toolController.onPointerDown(p)} onPointerMove={(p) => toolController.onPointerMove(p)} onPointerUp={(p) => toolController.onPointerUp(p)} @@ -64,10 +73,11 @@ export const MediaAssetContainer = (props: MediaAssetContainerProps) => { return ( a.id === selectedId)} - onUpdateAnnotation={onUpdateAnnotation} + onCommitAnnotation={onCommitAnnotation} > {surface} diff --git a/src/features/mediaStage/asset/video/VideoAsset.tsx b/src/features/mediaStage/asset/video/VideoAsset.tsx index e793781..2d402c6 100644 --- a/src/features/mediaStage/asset/video/VideoAsset.tsx +++ b/src/features/mediaStage/asset/video/VideoAsset.tsx @@ -11,8 +11,9 @@ interface VideoAssetProps { asset: MediaAsset; layout: MediaLayout; selectedAnnotation?: Annotation; - onUpdateAnnotation?: (annotation: Annotation) => void; + onCommitAnnotation?: (before: Annotation, after: Annotation) => void; isEditing?: boolean; + setActive?: (active: boolean) => void; children?: (size: { w: number; h: number; scaleX: number; scaleY: number }) => React.ReactNode; } @@ -20,7 +21,8 @@ export default function VideoAsset({ asset, layout, selectedAnnotation, - onUpdateAnnotation, + onCommitAnnotation, + setActive, isEditing, children, }: VideoAssetProps) { @@ -33,8 +35,6 @@ export default function VideoAsset({ const { duration, setDuration, cursor, setTime } = usePlayback(); - /* ---------------- lifecycle ---------------- */ - useEffect(() => { const video = videoRef.current; if (!video) return; @@ -51,8 +51,14 @@ export default function VideoAsset({ setTime(video.currentTime); }; - const handlePlay = () => setIsPlaying(true); - const handlePause = () => setIsPlaying(false); + const handlePlay = () => { + setActive?.(true); + setIsPlaying(true) + }; + const handlePause = () => { + setActive?.(false); + setIsPlaying(false) + }; video.addEventListener('loadedmetadata', handleLoaded); video.addEventListener('timeupdate', handleTimeUpdate); @@ -67,8 +73,6 @@ export default function VideoAsset({ }; }, [setDuration, setTime]); - /* ---------------- playback controls ---------------- */ - const play = useCallback(() => { videoRef.current?.play(); }, []); @@ -87,16 +91,15 @@ export default function VideoAsset({ const updateAnnotationInterval = useCallback( (interval: TimeRange) => { - if (!selectedAnnotation || !onUpdateAnnotation) return; - onUpdateAnnotation({ + if (!selectedAnnotation || !onCommitAnnotation) return; + onCommitAnnotation(selectedAnnotation,{ ...selectedAnnotation, time: interval, }); }, - [selectedAnnotation, onUpdateAnnotation], + [selectedAnnotation, onCommitAnnotation], ); - /* ---------------- render ---------------- */ return (
@@ -130,7 +133,7 @@ export default function VideoAsset({ onPlay={play} onPause={pause} onSeek={seek} - onUpdateAnnotationTime={updateAnnotationInterval} + onCommitAnnotationTime={updateAnnotationInterval} /> )}
diff --git a/src/features/mediaStage/asset/video/videoControls/VideoControls.tsx b/src/features/mediaStage/asset/video/videoControls/VideoControls.tsx index d41289d..c269e58 100644 --- a/src/features/mediaStage/asset/video/videoControls/VideoControls.tsx +++ b/src/features/mediaStage/asset/video/videoControls/VideoControls.tsx @@ -5,7 +5,7 @@ import { computeNextInterval, getTimeFromClientX, isValidInterval, -} from '../../../../../utils/videoTime.utils.ts'; +} from '../../../../../utils/videoTime.utils'; import { PlayPauseButton } from './PlayPauseButton'; import { HoverTimeBubble } from './HoverTimeBubble'; @@ -21,53 +21,68 @@ interface Props { onPlay: () => void; onPause: () => void; onSeek: (t: number) => void; - onUpdateAnnotationTime: (interval: TimeRange) => void; + onCommitAnnotationTime: (interval: TimeRange) => void; } type DragMode = 'move' | 'start' | 'end' | null; export default function VideoControls({ - duration, - currentTime, - isPlaying, - selectedAnnotation, - isEditing = false, - onPlay, - onPause, - onSeek, - onUpdateAnnotationTime, -}: Props) { + duration, + currentTime, + isPlaying, + selectedAnnotation, + isEditing = false, + onPlay, + onPause, + onSeek, + onCommitAnnotationTime, + }: Props) { const trackRef = useRef(null); const [hoverTime, setHoverTime] = useState(null); const [dragMode, setDragMode] = useState(null); - const interval = selectedAnnotation?.time ?? null; + /** Local draft interval used ONLY during dragging */ + const [draftInterval, setDraftInterval] = useState(null); + + /** Committed interval from annotation */ + const committedInterval = selectedAnnotation?.time ?? null; + + /** Interval to render (draft during drag, committed otherwise) */ + const interval = draftInterval ?? committedInterval; + const startDragging = (mode: DragMode) => { - if (!isEditing) return; + if (!isEditing || !selectedAnnotation) return; - if (isPlaying) { - onPause(); - } + if (isPlaying) onPause(); + setDraftInterval(selectedAnnotation.time); setDragMode(mode); }; const stopDragging = () => { + if (draftInterval) { + onCommitAnnotationTime(draftInterval); + } + + setDraftInterval(null); setDragMode(null); }; + useEffect(() => { - if (!dragMode || !interval || !trackRef.current) return; + if (!dragMode || !trackRef.current || !interval) return; + const onMove = (e: MouseEvent) => { const rect = trackRef.current!.getBoundingClientRect(); const t = getTimeFromClientX(e.clientX, rect, duration); onSeek(t); + const next = computeNextInterval(dragMode, t, interval); if (isValidInterval(next, duration)) { - onUpdateAnnotationTime(next); + setDraftInterval(next); } }; @@ -78,7 +93,8 @@ export default function VideoControls({ window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', stopDragging); }; - }, [dragMode, interval, duration, onSeek, onUpdateAnnotationTime]); + }, [dragMode, interval, duration, onSeek]); + const handleMouseMove = (e: React.MouseEvent) => { if (!trackRef.current) return; @@ -92,27 +108,38 @@ export default function VideoControls({ onSeek(getTimeFromClientX(e.clientX, rect, duration)); }; + return ( -
-
- - -
setHoverTime(null)} - onClick={handleClick} - > - {hoverTime !== null && } - - {interval && ( - - )} - - +
+
+ + +
setHoverTime(null)} + onClick={handleClick} + > + {hoverTime !== null && ( + + )} + + {interval && ( + + )} + + +
-
); } diff --git a/src/features/toolbox/ToolBox.tsx b/src/features/toolbox/ToolBox.tsx index 839671d..d075714 100644 --- a/src/features/toolbox/ToolBox.tsx +++ b/src/features/toolbox/ToolBox.tsx @@ -13,11 +13,11 @@ export const Toolbox = () => { selectedId, isEditing, activeTool, - setEditing, setActiveTool, selectAnnotation, updateAnnotation, + commitAnnotation, removeSelected, undo, redo, @@ -64,6 +64,7 @@ export const Toolbox = () => { updateAnnotation(selectedAnnotation.id, patch)} + onCommit={commitAnnotation} /> ) : (
diff --git a/src/features/toolbox/commands/StateCommandManager.ts b/src/features/toolbox/commands/StateCommandManager.ts new file mode 100644 index 0000000..c73ed36 --- /dev/null +++ b/src/features/toolbox/commands/StateCommandManager.ts @@ -0,0 +1,31 @@ +import type {Command} from "../../../types/intern/stateCommand.ts"; + +export class StateCommandManager { + private undoStack: Command[] = []; + private redoStack: Command[] = []; + + execute(cmd: Command) { + cmd.do(); + this.undoStack.push(cmd); + this.redoStack = []; + } + + undo() { + const cmd = this.undoStack.pop(); + if (!cmd) return; + cmd.undo(); + this.redoStack.push(cmd); + } + + redo() { + const cmd = this.redoStack.pop(); + if (!cmd) return; + cmd.do(); + this.undoStack.push(cmd); + } + + clear() { + this.undoStack = []; + this.redoStack = []; + } +} \ No newline at end of file diff --git a/src/features/toolbox/commands/stateCommands/AddAnnotationStateCommand.ts b/src/features/toolbox/commands/stateCommands/AddAnnotationStateCommand.ts new file mode 100644 index 0000000..723e8dd --- /dev/null +++ b/src/features/toolbox/commands/stateCommands/AddAnnotationStateCommand.ts @@ -0,0 +1,34 @@ +import type {Annotation} from "../../../../types/intern/annotation.ts"; +import type {Command} from "../../../../types/intern/stateCommand.ts"; + +export class AddAnnotationCommand implements Command { + private annotation: Annotation; + + private add: (a: Annotation) => void; + + private remove: (id: string) => void; + + private select?: (id: string | null) => void; + + constructor( + annotation: Annotation, + add: (a: Annotation) => void, + remove: (id: string) => void, + select?: (id: string | null) => void, + ) { + this.select = select; + this.remove = remove; + this.add = add; + this.annotation = annotation; + } + + do() { + this.add(this.annotation); + this.select?.(this.annotation.id); + } + + undo() { + this.remove(this.annotation.id); + this.select?.(null); + } +} diff --git a/src/features/toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts b/src/features/toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts new file mode 100644 index 0000000..a93888e --- /dev/null +++ b/src/features/toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts @@ -0,0 +1,34 @@ +import type {Command} from "../../../../types/intern/stateCommand.ts"; +import type {Annotation} from "../../../../types/intern/annotation.ts"; + +export class RemoveAnnotationCommand implements Command { + private annotation: Annotation; + + private add: (a: Annotation) => void; + + private remove: (id: string) => void; + + private select?: (id: string | null) => void; + + constructor( + annotation: Annotation, + add: (a: Annotation) => void, + remove: (id: string) => void, + select?: (id: string | null) => void, + ) { + this.select = select; + this.remove = remove; + this.add = add; + this.annotation = annotation; + } + + do() { + this.remove(this.annotation.id); + this.select?.(null); + } + + undo() { + this.add(this.annotation); + this.select?.(this.annotation.id); + } +} \ No newline at end of file diff --git a/src/features/toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts b/src/features/toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts new file mode 100644 index 0000000..5d9ed30 --- /dev/null +++ b/src/features/toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts @@ -0,0 +1,28 @@ +import type {Command} from "../../../../types/intern/stateCommand.ts"; +import type {Annotation} from "../../../../types/intern/annotation.ts"; + +export class UpdateAnnotationCommand implements Command { + private before: Annotation; + + private after: Annotation; + + private update: (id: string, a: Annotation) => void; + + constructor( + before: Annotation, + after: Annotation, + update: (id: string, a: Annotation) => void, + ) { + this.update = update; + this.after = after; + this.before = before; + } + + do() { + this.update(this.after.id, this.after); + } + + undo() { + this.update(this.before.id, this.before); + } +} diff --git a/src/features/toolbox/styleControls/BaseStyleControls.tsx b/src/features/toolbox/styleControls/BaseStyleControls.tsx index 8cfef21..958d82d 100644 --- a/src/features/toolbox/styleControls/BaseStyleControls.tsx +++ b/src/features/toolbox/styleControls/BaseStyleControls.tsx @@ -1,13 +1,14 @@ import * as Popover from '@radix-ui/react-popover'; import * as Slider from '@radix-ui/react-slider'; -import { HexColorPicker } from 'react-colorful'; -import { useState } from 'react'; +import {useRef} from 'react'; import type { Annotation, AnnotationPatch } from '../../../types/intern/annotation.ts'; +import {ColorPicker} from "./ColorPicker.tsx"; interface BaseStyleControlsProps { annotation: Annotation; onChange: (patch: AnnotationPatch) => void; + onCommit: (before: Annotation, after: Annotation) => void; } interface ControlSliderProps { @@ -16,74 +17,87 @@ interface ControlSliderProps { min: number; max: number; step: number; - onChange: (v: number) => void; + + onPreview: (v: number) => void; + onCommit: (before: number, after: number) => void; } -export const ControlSlider = ({ label, value, min, max, step, onChange }: ControlSliderProps) => { - const [internal, setInternal] = useState(value); - const percent = ((internal - min) / (max - min)) * 100; +export const ControlSlider = ({ label, + value, + min, + max, + step, + onPreview, + onCommit, }: ControlSliderProps) => { + const beforeRef = useRef(null); + + const percent = ((value - min) / (max - min)) * 100; + + const handlePointerDown = () => { + if (beforeRef.current === null) { + beforeRef.current = value; + } + }; + + const handlePointerUp = () => { + if (beforeRef.current !== null) { + onCommit(beforeRef.current, value); + beforeRef.current = null; + } + }; return ( -
-
{label}
+
+
{label}
-
- {internal.toFixed(step < 1 ? 2 : 0)} -
+
+ {value.toFixed(step < 1 ? 2 : 0)} +
- { - setInternal(v); - onChange(v); - }} - className="relative flex items-center h-5" - > - - - - - - + { + onPreview(v); + }} + className="relative flex items-center h-5" + > + + + + + + +
-
); }; -const BaseStyleControls = ({ annotation, onChange }: BaseStyleControlsProps) => { +const BaseStyleControls = ({ annotation, onChange, onCommit }: BaseStyleControlsProps) => { const color = annotation.style.color ?? '#ffffff'; const opacity = annotation.style.opacity ?? 1; const label = annotation.label ?? ''; + return (
- {/* LABEL — FIRST */} + {/* LABEL */}
Label
onChange({ label: e.target.value })} + onChange={(e) => onCommit(annotation, { ...annotation, label: e.target.value })} placeholder="Annotation label" className=" w-full @@ -119,7 +133,10 @@ const BaseStyleControls = ({ annotation, onChange }: BaseStyleControlsProps) => align="start" className="p-3 bg-neutral-900 rounded shadow-xl z-50" > - onChange({ style: { color: c } })} /> + onChange({ style: { color: c } })} onCommit={(before, after) => onCommit( + { ...annotation, style: { ...annotation.style, color: before } }, + { ...annotation, style: { ...annotation.style, color: after } }, + )}/> @@ -132,7 +149,13 @@ const BaseStyleControls = ({ annotation, onChange }: BaseStyleControlsProps) => max={1} step={0.01} value={opacity} - onChange={(v) => onChange({ style: { opacity: v } })} + onPreview={(v) => onChange({ style: { opacity: v } })} + onCommit={(before, after) => + onCommit( + { ...annotation, style: { ...annotation.style, opacity: before } }, + { ...annotation, style: { ...annotation.style, opacity: after } }, + ) + } />
); diff --git a/src/features/toolbox/styleControls/ColorPicker.tsx b/src/features/toolbox/styleControls/ColorPicker.tsx new file mode 100644 index 0000000..708da6f --- /dev/null +++ b/src/features/toolbox/styleControls/ColorPicker.tsx @@ -0,0 +1,42 @@ +import {useRef} from "react"; +import {HexColorPicker} from "react-colorful"; + +interface ColorPickerProps { + color: string; + onPreview: (color: string) => void; + onCommit: (before: string, after: string) => void; +} + +export const ColorPicker = ({ + color, + onPreview, + onCommit, + }: ColorPickerProps) => { + const beforeRef = useRef(null); + + const handleMouseDown = () => { + if (beforeRef.current === null) { + beforeRef.current = color; + } + }; + + const handleMouseUp = () => { + if (beforeRef.current !== null) { + onCommit(beforeRef.current, color); + beforeRef.current = null; + } + }; + + return ( +
+ onPreview(c)} + /> +
+ ); +}; \ No newline at end of file diff --git a/src/features/toolbox/styleControls/CustomTextControls.tsx b/src/features/toolbox/styleControls/CustomTextControls.tsx index 69663fd..0d94e76 100644 --- a/src/features/toolbox/styleControls/CustomTextControls.tsx +++ b/src/features/toolbox/styleControls/CustomTextControls.tsx @@ -5,9 +5,10 @@ import { Constants } from '../../../utils/Constants.ts'; interface TextControlsProps { textAnnotation: TextAnnotation; onChange: (patch: AnnotationPatch) => void; + onCommit?: (before: TextAnnotation, after: TextAnnotation) => void; } -const CustomTextControls = ({ textAnnotation, onChange }: TextControlsProps) => { +const CustomTextControls = ({ textAnnotation, onChange, onCommit }: TextControlsProps) => { const fontWeight = textAnnotation.fontWeight; const fontSize = textAnnotation.fontSize; @@ -42,7 +43,8 @@ const CustomTextControls = ({ textAnnotation, onChange }: TextControlsProps) => max={Constants.MAX_FONT_SIZE} step={1} value={fontSize} - onChange={(v) => onChange({ fontSize: v })} + onPreview={(v) => onChange({ fontSize: v })} + onCommit={(b, a) => onCommit && onCommit( { ...textAnnotation, fontSize: b }, { ...textAnnotation, fontSize: a })} />
{/* Font weight */} @@ -53,7 +55,8 @@ const CustomTextControls = ({ textAnnotation, onChange }: TextControlsProps) => max={900} step={100} value={fontWeight} - onChange={(v) => onChange({ fontWeight: v })} + onPreview={(v) => onChange({ fontWeight: v })} + onCommit={(b, a) => onCommit && onCommit( { ...textAnnotation, fontWeight: b }, { ...textAnnotation, fontWeight: a })} />
diff --git a/src/features/toolbox/styleControls/PolylineStyleControls.tsx b/src/features/toolbox/styleControls/PolylineStyleControls.tsx index cddc134..f863928 100644 --- a/src/features/toolbox/styleControls/PolylineStyleControls.tsx +++ b/src/features/toolbox/styleControls/PolylineStyleControls.tsx @@ -1,15 +1,16 @@ -import type { AnnotationPatch, PolylineAnnotation } from '../../../types/intern/annotation.ts'; +import type {AnnotationPatch, PolylineAnnotation} from '../../../types/intern/annotation.ts'; import { ControlSlider } from './BaseStyleControls.tsx'; import { Constants } from '../../../utils/Constants.ts'; import * as Popover from '@radix-ui/react-popover'; -import { HexColorPicker } from 'react-colorful'; +import {ColorPicker} from "./ColorPicker.tsx"; interface PolylineStyleControlsProps { annotation: PolylineAnnotation; onChange: (patch: AnnotationPatch) => void; + onCommit: (before: PolylineAnnotation, after: PolylineAnnotation) => void; } -const PolylineStyleControls = ({ annotation, onChange }: PolylineStyleControlsProps) => { +const PolylineStyleControls = ({ annotation, onChange, onCommit}: PolylineStyleControlsProps) => { const strokeWidth = annotation.style.strokeWidth ?? 1; const fill = annotation.style.fill ?? 'transparent'; return ( @@ -20,11 +21,15 @@ const PolylineStyleControls = ({ annotation, onChange }: PolylineStyleControlsPr max={Constants.MAX_STROKE_WIDTH} step={1} value={strokeWidth} - onChange={(v) => + onPreview={(v) => onChange({ style: { strokeWidth: v }, }) } + onCommit={(before, after) => onCommit( + { ...annotation, style: { ...annotation.style, strokeWidth: before } }, + { ...annotation, style: { ...annotation.style, strokeWidth: after } }, + )} /> {/* Color */}
@@ -44,7 +49,10 @@ const PolylineStyleControls = ({ annotation, onChange }: PolylineStyleControlsPr align="start" className="p-3 bg-neutral-900 rounded shadow-xl z-50" > - onChange({ style: { fill: c } })} /> + onChange({ style: { fill: c } })} onCommit={(before, after) => onCommit( + { ...annotation, style: { ...annotation.style, fill: before } }, + { ...annotation, style: { ...annotation.style, fill: after } }, + )}/> diff --git a/src/features/toolbox/styleControls/StyleControls.tsx b/src/features/toolbox/styleControls/StyleControls.tsx index b05f4b1..44e514b 100644 --- a/src/features/toolbox/styleControls/StyleControls.tsx +++ b/src/features/toolbox/styleControls/StyleControls.tsx @@ -8,9 +8,10 @@ import PolylineStyleControls from './PolylineStyleControls'; interface StyleControlsProps { annotation: Annotation; onChange: (patch: AnnotationPatch) => void; + onCommit: (before: Annotation, after: Annotation) => void; } -const StyleControls = ({ annotation, onChange }: StyleControlsProps) => { +const StyleControls = ({ annotation, onChange, onCommit }: StyleControlsProps) => { const isText = annotation.kind === 'text'; const isPolyline = annotation.kind === 'polyline'; @@ -18,11 +19,9 @@ const StyleControls = ({ annotation, onChange }: StyleControlsProps) => {
- - - {isPolyline && } - - {isText && } + + {isPolyline && } + {isText && }
diff --git a/src/features/toolbox/toolsContext/FreehandDrawTool.ts b/src/features/toolbox/toolsContext/FreehandDrawTool.ts index 621f4f5..9a7cbcd 100644 --- a/src/features/toolbox/toolsContext/FreehandDrawTool.ts +++ b/src/features/toolbox/toolsContext/FreehandDrawTool.ts @@ -49,6 +49,7 @@ export class FreehandDrawTool implements ToolStrategy { if (!this.annotationId) return; ctx.selectAnnotation(this.annotationId); + ctx.setSelectTool(); this.reset(); } diff --git a/src/features/toolbox/toolsContext/PolygonDrawTool.ts b/src/features/toolbox/toolsContext/PolygonDrawTool.ts new file mode 100644 index 0000000..bacdd0f --- /dev/null +++ b/src/features/toolbox/toolsContext/PolygonDrawTool.ts @@ -0,0 +1,117 @@ +import { Constants } from '../../../utils/Constants.ts'; +import type { ToolContextInterface, ToolStrategy } from './ToolContextInterface.ts'; +import type { Point } from '../../../types/geometry.ts'; + +export class PolygonDrawTool implements ToolStrategy { + private annotationId: string | null = null; + private points: number[] = []; + private counter = 0; + + onPointerDown(point: Point, ctx: ToolContextInterface) { + if (!this.annotationId) { + const id = crypto.randomUUID(); + this.counter += 1; + + const { currentTime, duration } = ctx.getTimeContext(); + + ctx.createAnnotation({ + id, + kind: 'polyline', + label: `Polygon ${this.counter}`, + points: [point.x, point.y], + time: { + start: currentTime, + end: Math.min( + currentTime + Constants.ANNOTATION_MIN_DURATION, + duration + ), + }, + style: { + color: Constants.POLYGON_DEFAULT_COLOR, + opacity: Constants.POLYGON_DEFAULT_OPACITY, + fill: 'none', + strokeWidth: Constants.POLYGON_DEFAULT_STROKE_WIDTH, + }, + }); + + this.annotationId = id; + this.points = [point.x, point.y]; + return; + } + + if (this.isClosingPoint(point)) { + this.finishPolygon(ctx); + return; + } + + this.points.push(point.x, point.y); + + ctx.updateAnnotation(this.annotationId, { + points: [...this.points], + }); + } + + + onPointerMove(point: Point, ctx: ToolContextInterface) { + if (!this.annotationId) return; + + ctx.updateAnnotation(this.annotationId, { + // last segment is a temporary straight preview + points: [...this.points, point.x, point.y], + }); + } + + + onPointerUp(_: Point, ctx: ToolContextInterface) { + if (!this.annotationId) return; + ctx.selectAnnotation(this.annotationId); + } + + cancel(ctx: ToolContextInterface) { + if (!this.annotationId) return; + ctx.removeAnnotation(this.annotationId); + this.reset(); + } + + private finishPolygon(ctx: ToolContextInterface) { + if (!this.annotationId) return; + + if (this.points.length < 6) { + ctx.removeAnnotation(this.annotationId); + this.reset(); + return; + } + + ctx.updateAnnotation(this.annotationId, { + points: [...this.points], + style: { + fill: Constants.POLYGON_DEFAULT_FILL, + }, + }); + + ctx.selectAnnotation(this.annotationId); + ctx.setSelectTool(); + this.reset(); + } + + + private isClosingPoint(point: Point): boolean { + if (this.points.length < 6) return false; + + const x0 = this.points[0]; + const y0 = this.points[1]; + + const dx = point.x - x0; + const dy = point.y - y0; + + return ( + Math.sqrt(dx * dx + dy * dy) < + Constants.POLYGON_CLOSE_DISTANCE + ); + } + + private reset() { + this.annotationId = null; + this.points = []; + } +} diff --git a/src/features/toolbox/toolsContext/RectDrawTool.ts b/src/features/toolbox/toolsContext/RectDrawTool.ts new file mode 100644 index 0000000..33bdcbc --- /dev/null +++ b/src/features/toolbox/toolsContext/RectDrawTool.ts @@ -0,0 +1,58 @@ +import { Constants } from '../../../utils/Constants.ts'; +import type { ToolContextInterface, ToolStrategy } from './ToolContextInterface.ts'; +import type { Point } from '../../../types/geometry.ts'; + +export class RectDrawTool implements ToolStrategy { + private counter = 0; + + onPointerDown(point: Point, ctx: ToolContextInterface) { + const id = crypto.randomUUID(); + this.counter += 1; + + const { currentTime, duration } = ctx.getTimeContext(); + + const halfWidth = Constants.DEFAULT_RECT_WIDTH / 2; + const halfHeight = Constants.DEFAULT_RECT_HEIGHT / 2; + + const x1 = point.x + halfWidth; + const y1 = point.y + halfHeight; + + const points = [ + point.x, point.y, + x1, point.y, + x1, y1, + point.x, y1, + point.x, point.y, + ]; + + ctx.createAnnotation({ + id, + kind: 'polyline', + label: `Rect ${this.counter}`, + points, + time: { + start: currentTime, + end: Math.min( + currentTime + Constants.ANNOTATION_MIN_DURATION, + duration + ), + }, + style: { + color: Constants.POLYLINE_DEFAULT_COLOR, + opacity: Constants.POLYLINE_DEFAULT_OPACITY, + fill: 'none', + strokeWidth: Constants.DEFAULT_STROKE_WIDTH_FOR_RECT, + }, + }); + + ctx.selectAnnotation(id); + ctx.setSelectTool(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onPointerMove(_: Point, __: ToolContextInterface) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onPointerUp(_: Point, __: ToolContextInterface) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + cancel(_: ToolContextInterface) {} +} diff --git a/src/features/toolbox/toolsContext/TextDrawTool.ts b/src/features/toolbox/toolsContext/TextDrawTool.ts index 868e33b..820b235 100644 --- a/src/features/toolbox/toolsContext/TextDrawTool.ts +++ b/src/features/toolbox/toolsContext/TextDrawTool.ts @@ -45,9 +45,8 @@ export class TextDrawTool implements ToolStrategy { // no-op } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onPointerUp(_: Point) { - // no-op + onPointerUp(_: Point, ctx: ToolContextInterface) { + ctx.setSelectTool(); } cancel(ctx: ToolContextInterface) { diff --git a/src/features/toolbox/toolsContext/ToolContextInterface.ts b/src/features/toolbox/toolsContext/ToolContextInterface.ts index 7b7c6b6..e77c304 100644 --- a/src/features/toolbox/toolsContext/ToolContextInterface.ts +++ b/src/features/toolbox/toolsContext/ToolContextInterface.ts @@ -11,6 +11,7 @@ export interface ToolContextInterface { updateAnnotation: (id: string, patch: AnnotationPatch) => void; removeAnnotation: (id: string) => void; selectAnnotation: (id: string | null) => void; + setSelectTool(): void; setTimeContext: (timeContext: ToolControllerTimeContext) => void; getTimeContext: () => ToolControllerTimeContext; } diff --git a/src/features/toolbox/toolsContext/ToolController.ts b/src/features/toolbox/toolsContext/ToolController.ts index 9ee256a..eaff418 100644 --- a/src/features/toolbox/toolsContext/ToolController.ts +++ b/src/features/toolbox/toolsContext/ToolController.ts @@ -6,44 +6,44 @@ import type { import type { Point } from '../../../types/geometry'; import type { EditorMutators } from '../../context/editor/EditorContext.types.ts'; import type { Annotation, AnnotationPatch } from '../../../types/intern/annotation.ts'; +import type {Tool} from "../tools/tools.items.tsx"; +import {Constants} from "../../../utils/Constants.ts"; export class ToolController implements ToolContextInterface { - private activeTool: ToolStrategy | null = null; + private activeToolStrategy: ToolStrategy | null = null; private readonly mutators: EditorMutators; - private readonly onFinish: () => void; + private readonly setActiveTool: (tool: Tool) => void; private time: ToolControllerTimeContext = { currentTime: 0, duration: 0 }; - constructor(mutators: EditorMutators, onFinish: () => void) { + constructor(mutators: EditorMutators, setActiveTool: (tool: Tool) => void) { this.mutators = mutators; - this.onFinish = onFinish; + this.setActiveTool = setActiveTool; } - /* ---------------- tool lifecycle ---------------- */ - setTool(tool: ToolStrategy | null) { - if (this.activeTool) { - this.activeTool.cancel(this); + setToolStrategy(tool: ToolStrategy | null) { + if (this.activeToolStrategy) { + this.activeToolStrategy.cancel(this); } - this.activeTool = tool; + this.activeToolStrategy = tool; } onEditingDisabled() { - this.activeTool?.cancel(this); - this.activeTool = null; + this.activeToolStrategy?.cancel(this); + this.activeToolStrategy = null; } /* ---------------- pointer events ---------------- */ onPointerDown(point: Point) { - this.activeTool?.onPointerDown(point, this); + this.activeToolStrategy?.onPointerDown(point, this); } onPointerMove(point: Point) { - this.activeTool?.onPointerMove(point, this); + this.activeToolStrategy?.onPointerMove(point, this); } onPointerUp(point: Point) { - this.activeTool?.onPointerUp(point, this); - this.onFinish(); + this.activeToolStrategy?.onPointerUp(point, this); } /* ---------------- ToolContextInterface ---------------- */ @@ -71,4 +71,8 @@ export class ToolController implements ToolContextInterface { getTimeContext(): ToolControllerTimeContext { return this.time; } + + setSelectTool(): void { + this.setActiveTool(Constants.SELECT_TOOL_LABEL as Tool); + } } diff --git a/src/features/toolbox/toolsContext/ToolRegistry.ts b/src/features/toolbox/toolsContext/ToolRegistry.ts index 7c0e6be..894fe62 100644 --- a/src/features/toolbox/toolsContext/ToolRegistry.ts +++ b/src/features/toolbox/toolsContext/ToolRegistry.ts @@ -2,6 +2,8 @@ import { Constants } from '../../../utils/Constants'; import { FreehandDrawTool } from './FreehandDrawTool'; import type { ToolContextInterface, ToolStrategy } from './ToolContextInterface'; import { TextDrawTool } from './TextDrawTool.ts'; +import {PolygonDrawTool} from "./PolygonDrawTool.ts"; +import {RectDrawTool} from "./RectDrawTool.ts"; export class ToolRegistry { private readonly tools = new Map(); @@ -11,6 +13,8 @@ export class ToolRegistry { this.tools.set(Constants.SELECT_TOOL_LABEL, null); this.tools.set(Constants.DRAW_TOOL_LABEL, new FreehandDrawTool()); this.tools.set(Constants.TEXT_TOOL_LABEL, new TextDrawTool()); + this.tools.set(Constants.POLYGON_TOOL_LABEL, new PolygonDrawTool()) + this.tools.set(Constants.RECT_TOOL_LABEL, new RectDrawTool()); } get(toolId: string): ToolStrategy | null { diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index 5413202..a518a7e 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -1,20 +1,15 @@ -import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { MediaAssetAnnotatorPage } from '../pages/MediaAssetAnnotatorPage.tsx'; export const AppRouter = () => { - const DEMO_URL = import.meta.env.VITE_DEMO_MEDIA_URL; - return ( + const DEMO_URL = import.meta.env.VITE_DEMO_MEDIA_URL; + return ( - - } - /> + } + /> } /> diff --git a/src/types/intern/stateCommand.ts b/src/types/intern/stateCommand.ts new file mode 100644 index 0000000..e008265 --- /dev/null +++ b/src/types/intern/stateCommand.ts @@ -0,0 +1,4 @@ +export interface Command { + do(): void; + undo(): void; +} \ No newline at end of file diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index b39b362..dd6428f 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -1,26 +1,69 @@ export class Constants { - // Media types + /* ============================================================ + * Media & Assets + * ============================================================ */ static VIDEO_ASSET_TYPE_LABEL = 'video'; static IMAGE_ASSET_TYPE_LABEL = 'image'; - static ANNOTATION_MIN_DURATION = 2; - //Annotation types + + /* ============================================================ + * Time + * ============================================================ */ + static ANNOTATION_MIN_DURATION = 1; + + /* ============================================================ + * Annotation Types + * ============================================================ */ static POLYLINE_TYPE_LABEL = 'polyline'; static TEXT_TYPE_LABEL = 'text'; - // Tool + /* ============================================================ + * Scene / Canvas + * ============================================================ */ static DEFAULT_SCENE_WIDTH = 960; static DEFAULT_SCENE_HEIGHT = 540; + + /* ============================================================ + * Tools + * ============================================================ */ static SELECT_TOOL_LABEL = 'select'; static RECT_TOOL_LABEL = 'rect'; static TEXT_TOOL_LABEL = 'text'; static DRAW_TOOL_LABEL = 'polyline'; static POLYGON_TOOL_LABEL = 'polygon'; + + /* ============================================================ + * Commands + * ============================================================ */ static EDIT_COMMAND_LABEL = 'edit'; static UNDO_COMMAND_LABEL = 'undo'; static REDO_COMMAND_LABEL = 'redo'; static DELETE_COMMAND_LABEL = 'delete'; static SAVE_COMMAND_LABEL = 'save'; - // Text + + /* ============================================================ + * Polygon Defaults + * ============================================================ */ + static POLYGON_CLOSE_DISTANCE = 10; + static POLYGON_DEFAULT_COLOR = '#00ff00'; + static POLYGON_DEFAULT_OPACITY = 1; + static POLYGON_DEFAULT_FILL = 'rgba(0,255,0,0.2)'; + static POLYGON_DEFAULT_STROKE_WIDTH = 6; + + /* ============================================================ + * Polyline Defaults + * ============================================================ */ + static POLYLINE_DEFAULT_COLOR = 'red'; + static POLYLINE_DEFAULT_STROKE_WIDTH = 7; + static POLYLINE_DEFAULT_OPACITY = 1; + static POLYLINE_DEFAULT_FILL = 'none'; + static DEFAULT_RECT_WIDTH = 50; + static DEFAULT_RECT_HEIGHT = 50; + static DEFAULT_STROKE_WIDTH_FOR_RECT=2.5 + static MAX_STROKE_WIDTH = 50; + + /* ============================================================ + * Text Defaults + * ============================================================ */ static TEXT_DEFAULT_FONT_SIZE = 25; static TEXT_DEFAULT_FONT_WEIGHT = 300; static TEXT_DEFAULT_COLOR = 'red'; @@ -30,17 +73,5 @@ export class Constants { static DEFAULT_FONT_FAMILY = 'sans-serif'; static MIN_FONT_SIZE = 8; static MAX_FONT_SIZE = 72; - static IMAGE_SCALE = 2; - - // Polyline - static POLYLINE_DEFAULT_COLOR = 'red'; - static POLYLINE_DEFAULT_STROKE_WIDTH = 7; - static POLYLINE_DEFAULT_OPACITY = 100; - static POLYLINE_DEFAULT_FILL = 'none'; - static MAX_STROKE_WIDTH = 50; - // Styles - static SELECTED_ANNOTATION_BLUR_COLOR = '#0022ff'; - static SELECTED_POLYLINE_ANNOTATION_DASH_PARAMETER = [4, 0.5]; - static SELECTED_ANNOTATION_BLUR = 3; } From a474ec2bfbaeee05909c4edc2ba6c99f659d871c Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Wed, 4 Feb 2026 14:04:16 +0100 Subject: [PATCH 02/14] [Fix] fixed annptation layer tie bounds for visible annotaitons --- README.md | 9 +- index.html | 26 +-- src/features/annotation/AnnotationsLayer.tsx | 10 +- .../annotation/PolylineAnnotation.tsx | 9 +- .../annotation/TextAnnotationShape.tsx | 2 +- .../context/editor/EditorProvider.tsx | 81 ++++---- src/features/mediaStage/MediaStage.tsx | 2 +- src/features/mediaStage/StageSurface.tsx | 2 +- .../mediaStage/asset/MediaAssetContainer.tsx | 5 +- .../mediaStage/asset/video/VideoAsset.tsx | 11 +- .../video/videoControls/VideoControls.tsx | 72 +++---- src/features/toolbox/ToolBox.tsx | 2 +- .../toolbox/commands/StateCommandManager.ts | 50 ++--- .../AddAnnotationStateCommand.ts | 50 ++--- .../RemoveAnnotationStateCommand.ts | 52 ++--- .../UpdateAnnotationStateCommand.ts | 36 ++-- .../styleControls/BaseStyleControls.tsx | 102 +++++----- .../toolbox/styleControls/ColorPicker.tsx | 63 +++--- .../styleControls/CustomTextControls.tsx | 10 +- .../styleControls/PolylineStyleControls.tsx | 22 ++- .../toolbox/styleControls/StyleControls.tsx | 16 +- .../toolbox/toolsContext/PolygonDrawTool.ts | 183 +++++++++--------- .../toolbox/toolsContext/RectDrawTool.ts | 95 ++++----- .../toolbox/toolsContext/ToolController.ts | 4 +- .../toolbox/toolsContext/ToolRegistry.ts | 6 +- src/types/intern/stateCommand.ts | 6 +- src/utils/Constants.ts | 3 +- 27 files changed, 455 insertions(+), 474 deletions(-) diff --git a/README.md b/README.md index e869427..7a0e4d7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ and is designed to work with the **Media Asset Annotator Service** backend. ## [![Vercel Deploy](https://deploy-badge.vercel.app/vercel/media-asset-annotator?style=for-the-badge&name=vercel+demo)](https://media-asset-annotator.vercel.app) --- + ### Application Entry The application uses **client-side routing**. @@ -16,7 +17,9 @@ A **default route (`/`)** is defined and automatically redirects to a demo annot - `/` → redirects to a predefined demo media asset - `/annotator?id=` → loads a media asset from the backend - `/annotator?url=` → loads an external media asset by URL + --- + ## Technology Stack ### Core Framework @@ -66,7 +69,9 @@ Start the development server: ```bash npm run dev ``` + Access the application using one of the following URLs: + - http://localhost:5173/ (default demo) - http://localhost:5173/annotator?id= - http://localhost:5173/annotator?url= @@ -88,7 +93,9 @@ To preview the production build locally, run: ```bash npm run preview ``` + Access the application using one of the following URLs: + - http://localhost:4173/ (default demo) - http://localhost:4173/annotator?id= -- http://localhost:4173/annotator?url= \ No newline at end of file +- http://localhost:4173/annotator?url= diff --git a/index.html b/index.html index ca9fc8b..4b9a546 100644 --- a/index.html +++ b/index.html @@ -1,15 +1,15 @@ - + - - + + - - - - Media Asset Annotator - - -
- - - \ No newline at end of file + + + + Media Asset Annotator + + +
+ + + diff --git a/src/features/annotation/AnnotationsLayer.tsx b/src/features/annotation/AnnotationsLayer.tsx index 26814f1..e647a88 100644 --- a/src/features/annotation/AnnotationsLayer.tsx +++ b/src/features/annotation/AnnotationsLayer.tsx @@ -21,22 +21,20 @@ export const AnnotationsLayer = ({ annotations, mediaType, currentTime, - onCommit, + onCommit, isEditing, - isActive, + isActive, selectedId, onSelect, }: Props) => { - - const visible = annotations.filter((a) => { + const visible = annotations.filter((a) => { if (mediaType === Constants.IMAGE_ASSET_TYPE_LABEL) return true; if (a.time.start == null && a.time.end == null) return true; - return a.time.end >= currentTime; + return a.time.start <= currentTime && a.time.end >= currentTime; }); const current = isActive ? visible : annotations; - return ( <> {current.map((a) => { diff --git a/src/features/annotation/PolylineAnnotation.tsx b/src/features/annotation/PolylineAnnotation.tsx index 9569088..fd16160 100644 --- a/src/features/annotation/PolylineAnnotation.tsx +++ b/src/features/annotation/PolylineAnnotation.tsx @@ -19,7 +19,6 @@ const PolylineAnnotationShape = ({ isSelected, onSelect, }: PolylineAnnotationProps) => { - const lineRef = useRef(null); const handleDragEnd = (e: Konva.KonvaEventObject) => { const node = e.target as Konva.Line; @@ -54,10 +53,7 @@ const PolylineAnnotationShape = ({ }); // REMOVE konva internal stage scale before storing - newPoints.push( - p.x / stageScaleX, - p.y / stageScaleY - ); + newPoints.push(p.x / stageScaleX, p.y / stageScaleY); } line.setAttrs({ @@ -67,8 +63,7 @@ const PolylineAnnotationShape = ({ scaleY: 1, }); - onCommit(annotation, - { + onCommit(annotation, { ...annotation, points: newPoints, }); diff --git a/src/features/annotation/TextAnnotationShape.tsx b/src/features/annotation/TextAnnotationShape.tsx index a421bff..d6bf0ba 100644 --- a/src/features/annotation/TextAnnotationShape.tsx +++ b/src/features/annotation/TextAnnotationShape.tsx @@ -16,7 +16,7 @@ interface EditableTextProps { const EditableText = ({ annotation, isEditing, - onCommit, + onCommit, isSelected, onSelect, }: EditableTextProps) => { diff --git a/src/features/context/editor/EditorProvider.tsx b/src/features/context/editor/EditorProvider.tsx index e219f56..1e3d9a3 100644 --- a/src/features/context/editor/EditorProvider.tsx +++ b/src/features/context/editor/EditorProvider.tsx @@ -16,10 +16,10 @@ import { } from '../../../types/mapper/annotationMapper.ts'; import { fetchAnnotations } from '../../../api/fetchAnnotations.ts'; import type { Tool } from '../../toolbox/tools/tools.items.tsx'; -import {StateCommandManager} from "../../toolbox/commands/StateCommandManager.ts"; -import {UpdateAnnotationCommand} from "../../toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts"; -import {AddAnnotationCommand} from "../../toolbox/commands/stateCommands/AddAnnotationStateCommand.ts"; -import {RemoveAnnotationCommand} from "../../toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts"; +import { StateCommandManager } from '../../toolbox/commands/StateCommandManager.ts'; +import { UpdateAnnotationCommand } from '../../toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts'; +import { AddAnnotationCommand } from '../../toolbox/commands/stateCommands/AddAnnotationStateCommand.ts'; +import { RemoveAnnotationCommand } from '../../toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts'; export const EditorProvider = ({ children }: { children: React.ReactNode }) => { const [selectedId, setSelectedId] = useState(null); @@ -31,57 +31,51 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { const addAnnotation = (a: Annotation) => { stateCommandsRef.current!.execute( - new AddAnnotationCommand( - a, - (a) => { - setAnnotations((prev) => [...prev, a]); - }, - (id) => { - setAnnotations((prev) => prev.filter((ann) => ann.id !== id)); - setSelectedId((prev) => (prev === id ? null : prev)); - } - ), + new AddAnnotationCommand( + a, + (a) => { + setAnnotations((prev) => [...prev, a]); + }, + (id) => { + setAnnotations((prev) => prev.filter((ann) => ann.id !== id)); + setSelectedId((prev) => (prev === id ? null : prev)); + }, + ), ); }; const commitUpdateAnnotation = (before: Annotation, after: Annotation) => { - if (!before) return - stateCommandsRef.current!.execute( - new UpdateAnnotationCommand( - before, - after, - updateAnnotation, - ), - ); + if (!before) return; + stateCommandsRef.current!.execute(new UpdateAnnotationCommand(before, after, updateAnnotation)); }; const updateAnnotation = (id: string, patch: AnnotationPatch) => { setAnnotations((prev) => - prev.map((a) => - a.id === id - ? { - ...a, - ...patch, - style: patch.style ? { ...a.style, ...patch.style } : a.style, - } - : a, - ), + prev.map((a) => + a.id === id + ? { + ...a, + ...patch, + style: patch.style ? { ...a.style, ...patch.style } : a.style, + } + : a, + ), ); - } + }; const removeAnnotation = (id: string) => { stateCommandsRef.current!.execute( - new RemoveAnnotationCommand( - annotations.find((a) => a.id === id)!, - (a) => { - setAnnotations((prev) => [...prev, a]); - setSelectedId(a.id); - }, - (id) => { - setAnnotations((prev) => prev.filter((ann) => ann.id !== id)); - setSelectedId((prev) => (prev === id ? null : prev)); - } - ), + new RemoveAnnotationCommand( + annotations.find((a) => a.id === id)!, + (a) => { + setAnnotations((prev) => [...prev, a]); + setSelectedId(a.id); + }, + (id) => { + setAnnotations((prev) => prev.filter((ann) => ann.id !== id)); + setSelectedId((prev) => (prev === id ? null : prev)); + }, + ), ); }; @@ -114,7 +108,6 @@ export const EditorProvider = ({ children }: { children: React.ReactNode }) => { if (!v) setSelectedId(null); }; - const toolControllerRef = useRef(null); const stateCommandsRef = useRef(null); const toolRegistryRef = useRef(null); diff --git a/src/features/mediaStage/MediaStage.tsx b/src/features/mediaStage/MediaStage.tsx index ee53907..c089231 100644 --- a/src/features/mediaStage/MediaStage.tsx +++ b/src/features/mediaStage/MediaStage.tsx @@ -10,7 +10,7 @@ export const MediaStage = () => { selectedId, isEditing, updateAnnotation, - commitAnnotation, + commitAnnotation, selectAnnotation, getToolController, } = useEditor(); diff --git a/src/features/mediaStage/StageSurface.tsx b/src/features/mediaStage/StageSurface.tsx index e719efb..1248819 100644 --- a/src/features/mediaStage/StageSurface.tsx +++ b/src/features/mediaStage/StageSurface.tsx @@ -51,7 +51,7 @@ export const StageSurface = ({ currentTime, mediaType, onSelect, - isActive, + isActive, onCommit, onPointerDown, onPointerMove, diff --git a/src/features/mediaStage/asset/MediaAssetContainer.tsx b/src/features/mediaStage/asset/MediaAssetContainer.tsx index 5a98860..8e25bb2 100644 --- a/src/features/mediaStage/asset/MediaAssetContainer.tsx +++ b/src/features/mediaStage/asset/MediaAssetContainer.tsx @@ -2,7 +2,7 @@ import { StageSurface } from '../StageSurface.tsx'; import { Constants } from '../../../utils/Constants.ts'; import VideoAsset from './video/VideoAsset.tsx'; import type { ToolController } from '../../toolbox/toolsContext/ToolController.ts'; -import type {Annotation} from '../../../types/intern/annotation.ts'; +import type { Annotation } from '../../../types/intern/annotation.ts'; import type { MediaAsset, MediaLayout } from '../../../types/intern/media.ts'; import ImageAsset from './image/ImageAsset.tsx'; @@ -30,14 +30,13 @@ export const MediaAssetContainer = (props: MediaAssetContainerProps) => { annotations, selectedId, isEditing, - isActive, + isActive, setActive, currentTime, toolController, onUpdateAnnotation, onCommitAnnotation, onSelectAnnotation, - } = props; const surface = (size: { w: number; h: number; scaleX: number; scaleY: number }) => ( diff --git a/src/features/mediaStage/asset/video/VideoAsset.tsx b/src/features/mediaStage/asset/video/VideoAsset.tsx index 2d402c6..733dd00 100644 --- a/src/features/mediaStage/asset/video/VideoAsset.tsx +++ b/src/features/mediaStage/asset/video/VideoAsset.tsx @@ -21,8 +21,8 @@ export default function VideoAsset({ asset, layout, selectedAnnotation, - onCommitAnnotation, - setActive, + onCommitAnnotation, + setActive, isEditing, children, }: VideoAssetProps) { @@ -53,11 +53,11 @@ export default function VideoAsset({ const handlePlay = () => { setActive?.(true); - setIsPlaying(true) + setIsPlaying(true); }; const handlePause = () => { setActive?.(false); - setIsPlaying(false) + setIsPlaying(false); }; video.addEventListener('loadedmetadata', handleLoaded); @@ -92,7 +92,7 @@ export default function VideoAsset({ const updateAnnotationInterval = useCallback( (interval: TimeRange) => { if (!selectedAnnotation || !onCommitAnnotation) return; - onCommitAnnotation(selectedAnnotation,{ + onCommitAnnotation(selectedAnnotation, { ...selectedAnnotation, time: interval, }); @@ -100,7 +100,6 @@ export default function VideoAsset({ [selectedAnnotation, onCommitAnnotation], ); - return (
{/* VIDEO VIEWPORT */} diff --git a/src/features/mediaStage/asset/video/videoControls/VideoControls.tsx b/src/features/mediaStage/asset/video/videoControls/VideoControls.tsx index c269e58..be7185d 100644 --- a/src/features/mediaStage/asset/video/videoControls/VideoControls.tsx +++ b/src/features/mediaStage/asset/video/videoControls/VideoControls.tsx @@ -27,16 +27,16 @@ interface Props { type DragMode = 'move' | 'start' | 'end' | null; export default function VideoControls({ - duration, - currentTime, - isPlaying, - selectedAnnotation, - isEditing = false, - onPlay, - onPause, - onSeek, - onCommitAnnotationTime, - }: Props) { + duration, + currentTime, + isPlaying, + selectedAnnotation, + isEditing = false, + onPlay, + onPause, + onSeek, + onCommitAnnotationTime, +}: Props) { const trackRef = useRef(null); const [hoverTime, setHoverTime] = useState(null); @@ -51,7 +51,6 @@ export default function VideoControls({ /** Interval to render (draft during drag, committed otherwise) */ const interval = draftInterval ?? committedInterval; - const startDragging = (mode: DragMode) => { if (!isEditing || !selectedAnnotation) return; @@ -70,7 +69,6 @@ export default function VideoControls({ setDragMode(null); }; - useEffect(() => { if (!dragMode || !trackRef.current || !interval) return; @@ -95,7 +93,6 @@ export default function VideoControls({ }; }, [dragMode, interval, duration, onSeek]); - const handleMouseMove = (e: React.MouseEvent) => { if (!trackRef.current) return; const rect = trackRef.current.getBoundingClientRect(); @@ -108,38 +105,27 @@ export default function VideoControls({ onSeek(getTimeFromClientX(e.clientX, rect, duration)); }; - return ( -
-
- - -
setHoverTime(null)} - onClick={handleClick} - > - {hoverTime !== null && ( - - )} - - {interval && ( - - )} - - -
+
+
+ + +
setHoverTime(null)} + onClick={handleClick} + > + {hoverTime !== null && } + + {interval && ( + + )} + +
+
); } diff --git a/src/features/toolbox/ToolBox.tsx b/src/features/toolbox/ToolBox.tsx index d075714..f430268 100644 --- a/src/features/toolbox/ToolBox.tsx +++ b/src/features/toolbox/ToolBox.tsx @@ -17,7 +17,7 @@ export const Toolbox = () => { setActiveTool, selectAnnotation, updateAnnotation, - commitAnnotation, + commitAnnotation, removeSelected, undo, redo, diff --git a/src/features/toolbox/commands/StateCommandManager.ts b/src/features/toolbox/commands/StateCommandManager.ts index c73ed36..212eb3f 100644 --- a/src/features/toolbox/commands/StateCommandManager.ts +++ b/src/features/toolbox/commands/StateCommandManager.ts @@ -1,31 +1,31 @@ -import type {Command} from "../../../types/intern/stateCommand.ts"; +import type { Command } from '../../../types/intern/stateCommand.ts'; export class StateCommandManager { - private undoStack: Command[] = []; - private redoStack: Command[] = []; + private undoStack: Command[] = []; + private redoStack: Command[] = []; - execute(cmd: Command) { - cmd.do(); - this.undoStack.push(cmd); - this.redoStack = []; - } + execute(cmd: Command) { + cmd.do(); + this.undoStack.push(cmd); + this.redoStack = []; + } - undo() { - const cmd = this.undoStack.pop(); - if (!cmd) return; - cmd.undo(); - this.redoStack.push(cmd); - } + undo() { + const cmd = this.undoStack.pop(); + if (!cmd) return; + cmd.undo(); + this.redoStack.push(cmd); + } - redo() { - const cmd = this.redoStack.pop(); - if (!cmd) return; - cmd.do(); - this.undoStack.push(cmd); - } + redo() { + const cmd = this.redoStack.pop(); + if (!cmd) return; + cmd.do(); + this.undoStack.push(cmd); + } - clear() { - this.undoStack = []; - this.redoStack = []; - } -} \ No newline at end of file + clear() { + this.undoStack = []; + this.redoStack = []; + } +} diff --git a/src/features/toolbox/commands/stateCommands/AddAnnotationStateCommand.ts b/src/features/toolbox/commands/stateCommands/AddAnnotationStateCommand.ts index 723e8dd..7d441b8 100644 --- a/src/features/toolbox/commands/stateCommands/AddAnnotationStateCommand.ts +++ b/src/features/toolbox/commands/stateCommands/AddAnnotationStateCommand.ts @@ -1,34 +1,34 @@ -import type {Annotation} from "../../../../types/intern/annotation.ts"; -import type {Command} from "../../../../types/intern/stateCommand.ts"; +import type { Annotation } from '../../../../types/intern/annotation.ts'; +import type { Command } from '../../../../types/intern/stateCommand.ts'; export class AddAnnotationCommand implements Command { - private annotation: Annotation; + private annotation: Annotation; - private add: (a: Annotation) => void; + private add: (a: Annotation) => void; - private remove: (id: string) => void; + private remove: (id: string) => void; - private select?: (id: string | null) => void; + private select?: (id: string | null) => void; - constructor( - annotation: Annotation, - add: (a: Annotation) => void, - remove: (id: string) => void, - select?: (id: string | null) => void, - ) { - this.select = select; - this.remove = remove; - this.add = add; - this.annotation = annotation; - } + constructor( + annotation: Annotation, + add: (a: Annotation) => void, + remove: (id: string) => void, + select?: (id: string | null) => void, + ) { + this.select = select; + this.remove = remove; + this.add = add; + this.annotation = annotation; + } - do() { - this.add(this.annotation); - this.select?.(this.annotation.id); - } + do() { + this.add(this.annotation); + this.select?.(this.annotation.id); + } - undo() { - this.remove(this.annotation.id); - this.select?.(null); - } + undo() { + this.remove(this.annotation.id); + this.select?.(null); + } } diff --git a/src/features/toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts b/src/features/toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts index a93888e..76686e2 100644 --- a/src/features/toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts +++ b/src/features/toolbox/commands/stateCommands/RemoveAnnotationStateCommand.ts @@ -1,34 +1,34 @@ -import type {Command} from "../../../../types/intern/stateCommand.ts"; -import type {Annotation} from "../../../../types/intern/annotation.ts"; +import type { Command } from '../../../../types/intern/stateCommand.ts'; +import type { Annotation } from '../../../../types/intern/annotation.ts'; export class RemoveAnnotationCommand implements Command { - private annotation: Annotation; + private annotation: Annotation; - private add: (a: Annotation) => void; + private add: (a: Annotation) => void; - private remove: (id: string) => void; + private remove: (id: string) => void; - private select?: (id: string | null) => void; + private select?: (id: string | null) => void; - constructor( - annotation: Annotation, - add: (a: Annotation) => void, - remove: (id: string) => void, - select?: (id: string | null) => void, - ) { - this.select = select; - this.remove = remove; - this.add = add; - this.annotation = annotation; - } + constructor( + annotation: Annotation, + add: (a: Annotation) => void, + remove: (id: string) => void, + select?: (id: string | null) => void, + ) { + this.select = select; + this.remove = remove; + this.add = add; + this.annotation = annotation; + } - do() { - this.remove(this.annotation.id); - this.select?.(null); - } + do() { + this.remove(this.annotation.id); + this.select?.(null); + } - undo() { - this.add(this.annotation); - this.select?.(this.annotation.id); - } -} \ No newline at end of file + undo() { + this.add(this.annotation); + this.select?.(this.annotation.id); + } +} diff --git a/src/features/toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts b/src/features/toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts index 5d9ed30..4990814 100644 --- a/src/features/toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts +++ b/src/features/toolbox/commands/stateCommands/UpdateAnnotationStateCommand.ts @@ -1,28 +1,24 @@ -import type {Command} from "../../../../types/intern/stateCommand.ts"; -import type {Annotation} from "../../../../types/intern/annotation.ts"; +import type { Command } from '../../../../types/intern/stateCommand.ts'; +import type { Annotation } from '../../../../types/intern/annotation.ts'; export class UpdateAnnotationCommand implements Command { - private before: Annotation; + private before: Annotation; - private after: Annotation; + private after: Annotation; - private update: (id: string, a: Annotation) => void; + private update: (id: string, a: Annotation) => void; - constructor( - before: Annotation, - after: Annotation, - update: (id: string, a: Annotation) => void, - ) { - this.update = update; - this.after = after; - this.before = before; - } + constructor(before: Annotation, after: Annotation, update: (id: string, a: Annotation) => void) { + this.update = update; + this.after = after; + this.before = before; + } - do() { - this.update(this.after.id, this.after); - } + do() { + this.update(this.after.id, this.after); + } - undo() { - this.update(this.before.id, this.before); - } + undo() { + this.update(this.before.id, this.before); + } } diff --git a/src/features/toolbox/styleControls/BaseStyleControls.tsx b/src/features/toolbox/styleControls/BaseStyleControls.tsx index 958d82d..576be97 100644 --- a/src/features/toolbox/styleControls/BaseStyleControls.tsx +++ b/src/features/toolbox/styleControls/BaseStyleControls.tsx @@ -1,9 +1,9 @@ import * as Popover from '@radix-ui/react-popover'; import * as Slider from '@radix-ui/react-slider'; -import {useRef} from 'react'; +import { useRef } from 'react'; import type { Annotation, AnnotationPatch } from '../../../types/intern/annotation.ts'; -import {ColorPicker} from "./ColorPicker.tsx"; +import { ColorPicker } from './ColorPicker.tsx'; interface BaseStyleControlsProps { annotation: Annotation; @@ -22,13 +22,15 @@ interface ControlSliderProps { onCommit: (before: number, after: number) => void; } -export const ControlSlider = ({ label, - value, - min, - max, - step, - onPreview, - onCommit, }: ControlSliderProps) => { +export const ControlSlider = ({ + label, + value, + min, + max, + step, + onPreview, + onCommit, +}: ControlSliderProps) => { const beforeRef = useRef(null); const percent = ((value - min) / (max - min)) * 100; @@ -47,40 +49,40 @@ export const ControlSlider = ({ label, }; return ( -
-
{label}
- +
+
{label}
+ +
-
- {value.toFixed(step < 1 ? 2 : 0)} -
- - { - onPreview(v); - }} - className="relative flex items-center h-5" - > - - - - - - + {value.toFixed(step < 1 ? 2 : 0)}
+ + { + onPreview(v); + }} + className="relative flex items-center h-5" + > + + + + + +
+
); }; @@ -133,10 +135,16 @@ const BaseStyleControls = ({ annotation, onChange, onCommit }: BaseStyleControls align="start" className="p-3 bg-neutral-900 rounded shadow-xl z-50" > - onChange({ style: { color: c } })} onCommit={(before, after) => onCommit( - { ...annotation, style: { ...annotation.style, color: before } }, - { ...annotation, style: { ...annotation.style, color: after } }, - )}/> + onChange({ style: { color: c } })} + onCommit={(before, after) => + onCommit( + { ...annotation, style: { ...annotation.style, color: before } }, + { ...annotation, style: { ...annotation.style, color: after } }, + ) + } + /> @@ -151,10 +159,10 @@ const BaseStyleControls = ({ annotation, onChange, onCommit }: BaseStyleControls value={opacity} onPreview={(v) => onChange({ style: { opacity: v } })} onCommit={(before, after) => - onCommit( - { ...annotation, style: { ...annotation.style, opacity: before } }, - { ...annotation, style: { ...annotation.style, opacity: after } }, - ) + onCommit( + { ...annotation, style: { ...annotation.style, opacity: before } }, + { ...annotation, style: { ...annotation.style, opacity: after } }, + ) } />
diff --git a/src/features/toolbox/styleControls/ColorPicker.tsx b/src/features/toolbox/styleControls/ColorPicker.tsx index 708da6f..90fb5ed 100644 --- a/src/features/toolbox/styleControls/ColorPicker.tsx +++ b/src/features/toolbox/styleControls/ColorPicker.tsx @@ -1,42 +1,35 @@ -import {useRef} from "react"; -import {HexColorPicker} from "react-colorful"; +import { useRef } from 'react'; +import { HexColorPicker } from 'react-colorful'; interface ColorPickerProps { - color: string; - onPreview: (color: string) => void; - onCommit: (before: string, after: string) => void; + color: string; + onPreview: (color: string) => void; + onCommit: (before: string, after: string) => void; } -export const ColorPicker = ({ - color, - onPreview, - onCommit, - }: ColorPickerProps) => { - const beforeRef = useRef(null); +export const ColorPicker = ({ color, onPreview, onCommit }: ColorPickerProps) => { + const beforeRef = useRef(null); - const handleMouseDown = () => { - if (beforeRef.current === null) { - beforeRef.current = color; - } - }; + const handleMouseDown = () => { + if (beforeRef.current === null) { + beforeRef.current = color; + } + }; - const handleMouseUp = () => { - if (beforeRef.current !== null) { - onCommit(beforeRef.current, color); - beforeRef.current = null; - } - }; + const handleMouseUp = () => { + if (beforeRef.current !== null) { + onCommit(beforeRef.current, color); + beforeRef.current = null; + } + }; - return ( -
- onPreview(c)} - /> -
- ); -}; \ No newline at end of file + return ( +
+ onPreview(c)} /> +
+ ); +}; diff --git a/src/features/toolbox/styleControls/CustomTextControls.tsx b/src/features/toolbox/styleControls/CustomTextControls.tsx index 0d94e76..66c7563 100644 --- a/src/features/toolbox/styleControls/CustomTextControls.tsx +++ b/src/features/toolbox/styleControls/CustomTextControls.tsx @@ -44,7 +44,10 @@ const CustomTextControls = ({ textAnnotation, onChange, onCommit }: TextControls step={1} value={fontSize} onPreview={(v) => onChange({ fontSize: v })} - onCommit={(b, a) => onCommit && onCommit( { ...textAnnotation, fontSize: b }, { ...textAnnotation, fontSize: a })} + onCommit={(b, a) => + onCommit && + onCommit({ ...textAnnotation, fontSize: b }, { ...textAnnotation, fontSize: a }) + } />
{/* Font weight */} @@ -56,7 +59,10 @@ const CustomTextControls = ({ textAnnotation, onChange, onCommit }: TextControls step={100} value={fontWeight} onPreview={(v) => onChange({ fontWeight: v })} - onCommit={(b, a) => onCommit && onCommit( { ...textAnnotation, fontWeight: b }, { ...textAnnotation, fontWeight: a })} + onCommit={(b, a) => + onCommit && + onCommit({ ...textAnnotation, fontWeight: b }, { ...textAnnotation, fontWeight: a }) + } />
diff --git a/src/features/toolbox/styleControls/PolylineStyleControls.tsx b/src/features/toolbox/styleControls/PolylineStyleControls.tsx index f863928..852194f 100644 --- a/src/features/toolbox/styleControls/PolylineStyleControls.tsx +++ b/src/features/toolbox/styleControls/PolylineStyleControls.tsx @@ -1,8 +1,8 @@ -import type {AnnotationPatch, PolylineAnnotation} from '../../../types/intern/annotation.ts'; +import type { AnnotationPatch, PolylineAnnotation } from '../../../types/intern/annotation.ts'; import { ControlSlider } from './BaseStyleControls.tsx'; import { Constants } from '../../../utils/Constants.ts'; import * as Popover from '@radix-ui/react-popover'; -import {ColorPicker} from "./ColorPicker.tsx"; +import { ColorPicker } from './ColorPicker.tsx'; interface PolylineStyleControlsProps { annotation: PolylineAnnotation; @@ -10,7 +10,7 @@ interface PolylineStyleControlsProps { onCommit: (before: PolylineAnnotation, after: PolylineAnnotation) => void; } -const PolylineStyleControls = ({ annotation, onChange, onCommit}: PolylineStyleControlsProps) => { +const PolylineStyleControls = ({ annotation, onChange, onCommit }: PolylineStyleControlsProps) => { const strokeWidth = annotation.style.strokeWidth ?? 1; const fill = annotation.style.fill ?? 'transparent'; return ( @@ -26,10 +26,12 @@ const PolylineStyleControls = ({ annotation, onChange, onCommit}: PolylineStyleC style: { strokeWidth: v }, }) } - onCommit={(before, after) => onCommit( + onCommit={(before, after) => + onCommit( { ...annotation, style: { ...annotation.style, strokeWidth: before } }, { ...annotation, style: { ...annotation.style, strokeWidth: after } }, - )} + ) + } /> {/* Color */}
@@ -49,10 +51,16 @@ const PolylineStyleControls = ({ annotation, onChange, onCommit}: PolylineStyleC align="start" className="p-3 bg-neutral-900 rounded shadow-xl z-50" > - onChange({ style: { fill: c } })} onCommit={(before, after) => onCommit( + onChange({ style: { fill: c } })} + onCommit={(before, after) => + onCommit( { ...annotation, style: { ...annotation.style, fill: before } }, { ...annotation, style: { ...annotation.style, fill: after } }, - )}/> + ) + } + /> diff --git a/src/features/toolbox/styleControls/StyleControls.tsx b/src/features/toolbox/styleControls/StyleControls.tsx index 44e514b..b6a04ce 100644 --- a/src/features/toolbox/styleControls/StyleControls.tsx +++ b/src/features/toolbox/styleControls/StyleControls.tsx @@ -20,8 +20,20 @@ const StyleControls = ({ annotation, onChange, onCommit }: StyleControlsProps) =
- {isPolyline && } - {isText && } + {isPolyline && ( + + )} + {isText && ( + + )}
diff --git a/src/features/toolbox/toolsContext/PolygonDrawTool.ts b/src/features/toolbox/toolsContext/PolygonDrawTool.ts index bacdd0f..cba95bd 100644 --- a/src/features/toolbox/toolsContext/PolygonDrawTool.ts +++ b/src/features/toolbox/toolsContext/PolygonDrawTool.ts @@ -3,115 +3,106 @@ import type { ToolContextInterface, ToolStrategy } from './ToolContextInterface. import type { Point } from '../../../types/geometry.ts'; export class PolygonDrawTool implements ToolStrategy { - private annotationId: string | null = null; - private points: number[] = []; - private counter = 0; - - onPointerDown(point: Point, ctx: ToolContextInterface) { - if (!this.annotationId) { - const id = crypto.randomUUID(); - this.counter += 1; - - const { currentTime, duration } = ctx.getTimeContext(); - - ctx.createAnnotation({ - id, - kind: 'polyline', - label: `Polygon ${this.counter}`, - points: [point.x, point.y], - time: { - start: currentTime, - end: Math.min( - currentTime + Constants.ANNOTATION_MIN_DURATION, - duration - ), - }, - style: { - color: Constants.POLYGON_DEFAULT_COLOR, - opacity: Constants.POLYGON_DEFAULT_OPACITY, - fill: 'none', - strokeWidth: Constants.POLYGON_DEFAULT_STROKE_WIDTH, - }, - }); - - this.annotationId = id; - this.points = [point.x, point.y]; - return; - } - - if (this.isClosingPoint(point)) { - this.finishPolygon(ctx); - return; - } - - this.points.push(point.x, point.y); - - ctx.updateAnnotation(this.annotationId, { - points: [...this.points], - }); + private annotationId: string | null = null; + private points: number[] = []; + private counter = 0; + + onPointerDown(point: Point, ctx: ToolContextInterface) { + if (!this.annotationId) { + const id = crypto.randomUUID(); + this.counter += 1; + + const { currentTime, duration } = ctx.getTimeContext(); + + ctx.createAnnotation({ + id, + kind: 'polyline', + label: `Polygon ${this.counter}`, + points: [point.x, point.y], + time: { + start: currentTime, + end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + }, + style: { + color: Constants.POLYGON_DEFAULT_COLOR, + opacity: Constants.POLYGON_DEFAULT_OPACITY, + fill: 'none', + strokeWidth: Constants.POLYGON_DEFAULT_STROKE_WIDTH, + }, + }); + + this.annotationId = id; + this.points = [point.x, point.y]; + return; } + if (this.isClosingPoint(point)) { + this.finishPolygon(ctx); + return; + } - onPointerMove(point: Point, ctx: ToolContextInterface) { - if (!this.annotationId) return; + this.points.push(point.x, point.y); - ctx.updateAnnotation(this.annotationId, { - // last segment is a temporary straight preview - points: [...this.points, point.x, point.y], - }); - } + ctx.updateAnnotation(this.annotationId, { + points: [...this.points], + }); + } + onPointerMove(point: Point, ctx: ToolContextInterface) { + if (!this.annotationId) return; - onPointerUp(_: Point, ctx: ToolContextInterface) { - if (!this.annotationId) return; - ctx.selectAnnotation(this.annotationId); - } + ctx.updateAnnotation(this.annotationId, { + // last segment is a temporary straight preview + points: [...this.points, point.x, point.y], + }); + } - cancel(ctx: ToolContextInterface) { - if (!this.annotationId) return; - ctx.removeAnnotation(this.annotationId); - this.reset(); - } - private finishPolygon(ctx: ToolContextInterface) { - if (!this.annotationId) return; - - if (this.points.length < 6) { - ctx.removeAnnotation(this.annotationId); - this.reset(); - return; - } - - ctx.updateAnnotation(this.annotationId, { - points: [...this.points], - style: { - fill: Constants.POLYGON_DEFAULT_FILL, - }, - }); - - ctx.selectAnnotation(this.annotationId); - ctx.setSelectTool(); - this.reset(); +// eslint-disable-next-line @typescript-eslint/no-unused-vars + onPointerUp(_: Point, __: ToolContextInterface) { + } + + cancel(ctx: ToolContextInterface) { + if (!this.annotationId) return; + ctx.removeAnnotation(this.annotationId); + this.reset(); + } + + private finishPolygon(ctx: ToolContextInterface) { + if (!this.annotationId) return; + + if (this.points.length < 6) { + ctx.removeAnnotation(this.annotationId); + this.reset(); + return; } + ctx.updateAnnotation(this.annotationId, { + points: [...this.points], + style: { + fill: Constants.POLYGON_DEFAULT_FILL, + }, + }); - private isClosingPoint(point: Point): boolean { - if (this.points.length < 6) return false; + ctx.selectAnnotation(this.annotationId); + ctx.setSelectTool(); + this.reset(); + } - const x0 = this.points[0]; - const y0 = this.points[1]; + private isClosingPoint(point: Point): boolean { + if (this.points.length < 6) return false; - const dx = point.x - x0; - const dy = point.y - y0; + const x0 = this.points[0]; + const y0 = this.points[1]; - return ( - Math.sqrt(dx * dx + dy * dy) < - Constants.POLYGON_CLOSE_DISTANCE - ); - } + const dx = point.x - x0; + const dy = point.y - y0; - private reset() { - this.annotationId = null; - this.points = []; - } + return Math.sqrt(dx * dx + dy * dy) < Constants.POLYGON_CLOSE_DISTANCE; + } + + private reset() { + this.annotationId = null; + this.points = []; + } } diff --git a/src/features/toolbox/toolsContext/RectDrawTool.ts b/src/features/toolbox/toolsContext/RectDrawTool.ts index 33bdcbc..75f3420 100644 --- a/src/features/toolbox/toolsContext/RectDrawTool.ts +++ b/src/features/toolbox/toolsContext/RectDrawTool.ts @@ -3,56 +3,47 @@ import type { ToolContextInterface, ToolStrategy } from './ToolContextInterface. import type { Point } from '../../../types/geometry.ts'; export class RectDrawTool implements ToolStrategy { - private counter = 0; - - onPointerDown(point: Point, ctx: ToolContextInterface) { - const id = crypto.randomUUID(); - this.counter += 1; - - const { currentTime, duration } = ctx.getTimeContext(); - - const halfWidth = Constants.DEFAULT_RECT_WIDTH / 2; - const halfHeight = Constants.DEFAULT_RECT_HEIGHT / 2; - - const x1 = point.x + halfWidth; - const y1 = point.y + halfHeight; - - const points = [ - point.x, point.y, - x1, point.y, - x1, y1, - point.x, y1, - point.x, point.y, - ]; - - ctx.createAnnotation({ - id, - kind: 'polyline', - label: `Rect ${this.counter}`, - points, - time: { - start: currentTime, - end: Math.min( - currentTime + Constants.ANNOTATION_MIN_DURATION, - duration - ), - }, - style: { - color: Constants.POLYLINE_DEFAULT_COLOR, - opacity: Constants.POLYLINE_DEFAULT_OPACITY, - fill: 'none', - strokeWidth: Constants.DEFAULT_STROKE_WIDTH_FOR_RECT, - }, - }); - - ctx.selectAnnotation(id); - ctx.setSelectTool(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onPointerMove(_: Point, __: ToolContextInterface) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onPointerUp(_: Point, __: ToolContextInterface) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - cancel(_: ToolContextInterface) {} + private counter = 0; + + onPointerDown(point: Point, ctx: ToolContextInterface) { + const id = crypto.randomUUID(); + this.counter += 1; + + const { currentTime, duration } = ctx.getTimeContext(); + + const halfWidth = Constants.DEFAULT_RECT_WIDTH / 2; + const halfHeight = Constants.DEFAULT_RECT_HEIGHT / 2; + + const x1 = point.x + halfWidth; + const y1 = point.y + halfHeight; + + const points = [point.x, point.y, x1, point.y, x1, y1, point.x, y1, point.x, point.y]; + + ctx.createAnnotation({ + id, + kind: 'polyline', + label: `Rect ${this.counter}`, + points, + time: { + start: currentTime, + end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + }, + style: { + color: Constants.POLYLINE_DEFAULT_COLOR, + opacity: Constants.POLYLINE_DEFAULT_OPACITY, + fill: 'none', + strokeWidth: Constants.DEFAULT_STROKE_WIDTH_FOR_RECT, + }, + }); + + ctx.selectAnnotation(id); + ctx.setSelectTool(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onPointerMove(_: Point, __: ToolContextInterface) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onPointerUp(_: Point, __: ToolContextInterface) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + cancel(_: ToolContextInterface) {} } diff --git a/src/features/toolbox/toolsContext/ToolController.ts b/src/features/toolbox/toolsContext/ToolController.ts index eaff418..fa4279e 100644 --- a/src/features/toolbox/toolsContext/ToolController.ts +++ b/src/features/toolbox/toolsContext/ToolController.ts @@ -6,8 +6,8 @@ import type { import type { Point } from '../../../types/geometry'; import type { EditorMutators } from '../../context/editor/EditorContext.types.ts'; import type { Annotation, AnnotationPatch } from '../../../types/intern/annotation.ts'; -import type {Tool} from "../tools/tools.items.tsx"; -import {Constants} from "../../../utils/Constants.ts"; +import type { Tool } from '../tools/tools.items.tsx'; +import { Constants } from '../../../utils/Constants.ts'; export class ToolController implements ToolContextInterface { private activeToolStrategy: ToolStrategy | null = null; diff --git a/src/features/toolbox/toolsContext/ToolRegistry.ts b/src/features/toolbox/toolsContext/ToolRegistry.ts index 894fe62..05395e7 100644 --- a/src/features/toolbox/toolsContext/ToolRegistry.ts +++ b/src/features/toolbox/toolsContext/ToolRegistry.ts @@ -2,8 +2,8 @@ import { Constants } from '../../../utils/Constants'; import { FreehandDrawTool } from './FreehandDrawTool'; import type { ToolContextInterface, ToolStrategy } from './ToolContextInterface'; import { TextDrawTool } from './TextDrawTool.ts'; -import {PolygonDrawTool} from "./PolygonDrawTool.ts"; -import {RectDrawTool} from "./RectDrawTool.ts"; +import { PolygonDrawTool } from './PolygonDrawTool.ts'; +import { RectDrawTool } from './RectDrawTool.ts'; export class ToolRegistry { private readonly tools = new Map(); @@ -13,7 +13,7 @@ export class ToolRegistry { this.tools.set(Constants.SELECT_TOOL_LABEL, null); this.tools.set(Constants.DRAW_TOOL_LABEL, new FreehandDrawTool()); this.tools.set(Constants.TEXT_TOOL_LABEL, new TextDrawTool()); - this.tools.set(Constants.POLYGON_TOOL_LABEL, new PolygonDrawTool()) + this.tools.set(Constants.POLYGON_TOOL_LABEL, new PolygonDrawTool()); this.tools.set(Constants.RECT_TOOL_LABEL, new RectDrawTool()); } diff --git a/src/types/intern/stateCommand.ts b/src/types/intern/stateCommand.ts index e008265..cc458e9 100644 --- a/src/types/intern/stateCommand.ts +++ b/src/types/intern/stateCommand.ts @@ -1,4 +1,4 @@ export interface Command { - do(): void; - undo(): void; -} \ No newline at end of file + do(): void; + undo(): void; +} diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index dd6428f..11dd50b 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -58,7 +58,7 @@ export class Constants { static POLYLINE_DEFAULT_FILL = 'none'; static DEFAULT_RECT_WIDTH = 50; static DEFAULT_RECT_HEIGHT = 50; - static DEFAULT_STROKE_WIDTH_FOR_RECT=2.5 + static DEFAULT_STROKE_WIDTH_FOR_RECT = 2.5; static MAX_STROKE_WIDTH = 50; /* ============================================================ @@ -73,5 +73,4 @@ export class Constants { static DEFAULT_FONT_FAMILY = 'sans-serif'; static MIN_FONT_SIZE = 8; static MAX_FONT_SIZE = 72; - } From 12b677fd114d659d57406f5d376a39eaa7b69e1d Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Wed, 4 Feb 2026 17:55:48 +0100 Subject: [PATCH 03/14] [Upd] dockerfile added. Custom configuration for nginx. --- .docker/entrypoint.sh | 14 ++++++ .docker/nginx.conf.template | 28 +++++++++++ .dockerignore | 7 +++ .env.example | 3 +- Dockerfile | 46 +++++++++++++++++++ index.html | 1 + public/runtime-env.js | 5 ++ src/api/fetchAnnotations.ts | 7 +-- src/api/fetchMediaAsset.ts | 3 +- src/features/annotation/AnnotationsLayer.tsx | 2 +- .../toolbox/toolsContext/FreehandDrawTool.ts | 2 +- .../toolbox/toolsContext/PolygonDrawTool.ts | 8 ++-- .../toolbox/toolsContext/RectDrawTool.ts | 2 +- .../toolbox/toolsContext/TextDrawTool.ts | 2 +- src/router/AppRouter.tsx | 4 +- src/utils/Constants.ts | 2 +- src/utils/runtimeConfig.ts | 20 ++++++++ src/utils/videoTime.utils.ts | 4 +- vite.config.ts | 1 + 19 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 .docker/entrypoint.sh create mode 100644 .docker/nginx.conf.template create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 public/runtime-env.js create mode 100644 src/utils/runtimeConfig.ts diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh new file mode 100644 index 0000000..7c94ee5 --- /dev/null +++ b/.docker/entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +envsubst \ + '$ANNOTATIONS_FETCH_API_URL \ + $MEDIA_ASSET_FETCH_API_URL \ + $DEMO_MEDIA_URL' \ + < /usr/share/nginx/html/runtime-env.js \ + > /tmp/runtime-env.js + +mv /tmp/runtime-env.js \ + /usr/share/nginx/html/runtime-env.js + +exec "$@" diff --git a/.docker/nginx.conf.template b/.docker/nginx.conf.template new file mode 100644 index 0000000..d094b8b --- /dev/null +++ b/.docker/nginx.conf.template @@ -0,0 +1,28 @@ +worker_processes auto; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + + server { + listen 8080; + + # Base path injected via env, e.g. /annotator + location ${BASE_PATH}/ { + alias /usr/share/nginx/html/; + + index index.html; + + # SPA routing + try_files $uri $uri/ ${BASE_PATH}/index.html; + } + + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e8118ab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.git +.gitignore +Dockerfile +.env* +npm-debug.log \ No newline at end of file diff --git a/.env.example b/.env.example index 79902f9..29b2c84 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ VITE_ANNOTATIONS_FETCH_API_URL=https://api.example.com/annotations VITE_MEDIA_ASSET_FETCH_API_URL=https://api.example.com/media-assets -VITE_DEMO_MEDIA_URL=https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4 \ No newline at end of file +VITE_DEMO_MEDIA_URL=https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4 +BASE_PATH= \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e3b2dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +######################################## +# 1. Dependencies +######################################## +FROM node:20-alpine AS deps + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + + +######################################## +# 2. Build +######################################## +FROM node:20-alpine AS build + +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + + +######################################## +# 3. Runtime (nginx) +######################################## +FROM nginx:1.25-alpine + +# Remove default nginx config +RUN rm /etc/nginx/conf.d/default.conf + +# Copy build output +COPY --from=build /app/dist /usr/share/nginx/html + +# Custom nginx template +COPY nginx.conf.template /etc/nginx/templates/nginx.conf.template + +# Entrypoint +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Run as non-root +USER nginx + +EXPOSE 8080 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["nginx", "-g", "daemon off;"] diff --git a/index.html b/index.html index 4b9a546..93ca88a 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@
+ diff --git a/public/runtime-env.js b/public/runtime-env.js new file mode 100644 index 0000000..d5b4083 --- /dev/null +++ b/public/runtime-env.js @@ -0,0 +1,5 @@ +window.__RUNTIME_CONFIG__ = { + ANNOTATIONS_FETCH_API_URL: "__ANNOTATIONS_FETCH_API_URL__", + MEDIA_ASSET_FETCH_API_URL: "__MEDIA_ASSET_FETCH_API_URL__", + DEMO_MEDIA_URL: "__DEMO_MEDIA_URL__", +}; \ No newline at end of file diff --git a/src/api/fetchAnnotations.ts b/src/api/fetchAnnotations.ts index 42c99e4..43704ea 100644 --- a/src/api/fetchAnnotations.ts +++ b/src/api/fetchAnnotations.ts @@ -1,7 +1,7 @@ import type { AnnotationData } from '../types/extern/annotationData.ts'; import { mockAnnotations } from './mocks/annotatios'; +import { runtimeConfig } from '../utils/runtimeConfig.ts'; -const ANNOTATIONS_FETCH_API_URL = import.meta.env.VITE_ANNOTATIONS_FETCH_API_URL as string; export const fetchAnnotations = async (mediaAssetId: string): Promise => { if (import.meta.env.DEV) { @@ -10,11 +10,8 @@ export const fetchAnnotations = async (mediaAssetId: string): Promise => { if (import.meta.env.DEV) { return mockVideoMediaAsset; } - const response = await fetch(`${import.meta.env.VITE_MEDIA_ASSET_FETCH_API_URL}/${mediaAssetId}`); + const response = await fetch(`${runtimeConfig.MEDIA_ASSET_FETCH_API_URL}/${mediaAssetId}`); if (!response.ok) { throw new Error('Failed to fetch media asset'); diff --git a/src/features/annotation/AnnotationsLayer.tsx b/src/features/annotation/AnnotationsLayer.tsx index e647a88..8e551cc 100644 --- a/src/features/annotation/AnnotationsLayer.tsx +++ b/src/features/annotation/AnnotationsLayer.tsx @@ -30,7 +30,7 @@ export const AnnotationsLayer = ({ const visible = annotations.filter((a) => { if (mediaType === Constants.IMAGE_ASSET_TYPE_LABEL) return true; if (a.time.start == null && a.time.end == null) return true; - return a.time.start <= currentTime && a.time.end >= currentTime; + return a.time.start <= currentTime && a.time.end >= currentTime; }); const current = isActive ? visible : annotations; diff --git a/src/features/toolbox/toolsContext/FreehandDrawTool.ts b/src/features/toolbox/toolsContext/FreehandDrawTool.ts index 9a7cbcd..e13c1aa 100644 --- a/src/features/toolbox/toolsContext/FreehandDrawTool.ts +++ b/src/features/toolbox/toolsContext/FreehandDrawTool.ts @@ -21,7 +21,7 @@ export class FreehandDrawTool implements ToolStrategy { points: [point.x, point.y], time: { start: currentTime, - end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + end: Math.min(currentTime + Constants.ANNOTATION_DEFAULT_DURATION, duration), }, style: { color: Constants.POLYLINE_DEFAULT_COLOR, diff --git a/src/features/toolbox/toolsContext/PolygonDrawTool.ts b/src/features/toolbox/toolsContext/PolygonDrawTool.ts index cba95bd..21fe1cd 100644 --- a/src/features/toolbox/toolsContext/PolygonDrawTool.ts +++ b/src/features/toolbox/toolsContext/PolygonDrawTool.ts @@ -21,7 +21,7 @@ export class PolygonDrawTool implements ToolStrategy { points: [point.x, point.y], time: { start: currentTime, - end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + end: Math.min(currentTime + Constants.ANNOTATION_DEFAULT_DURATION, duration), }, style: { color: Constants.POLYGON_DEFAULT_COLOR, @@ -57,10 +57,8 @@ export class PolygonDrawTool implements ToolStrategy { }); } - -// eslint-disable-next-line @typescript-eslint/no-unused-vars - onPointerUp(_: Point, __: ToolContextInterface) { - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onPointerUp(_: Point, __: ToolContextInterface) {} cancel(ctx: ToolContextInterface) { if (!this.annotationId) return; diff --git a/src/features/toolbox/toolsContext/RectDrawTool.ts b/src/features/toolbox/toolsContext/RectDrawTool.ts index 75f3420..0a04012 100644 --- a/src/features/toolbox/toolsContext/RectDrawTool.ts +++ b/src/features/toolbox/toolsContext/RectDrawTool.ts @@ -26,7 +26,7 @@ export class RectDrawTool implements ToolStrategy { points, time: { start: currentTime, - end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + end: Math.min(currentTime + Constants.ANNOTATION_DEFAULT_DURATION, duration), }, style: { color: Constants.POLYLINE_DEFAULT_COLOR, diff --git a/src/features/toolbox/toolsContext/TextDrawTool.ts b/src/features/toolbox/toolsContext/TextDrawTool.ts index 820b235..9cc0779 100644 --- a/src/features/toolbox/toolsContext/TextDrawTool.ts +++ b/src/features/toolbox/toolsContext/TextDrawTool.ts @@ -23,7 +23,7 @@ export class TextDrawTool implements ToolStrategy { y: point.y, time: { start: currentTime, - end: Math.min(currentTime + Constants.ANNOTATION_MIN_DURATION, duration), + end: Math.min(currentTime + Constants.ANNOTATION_DEFAULT_DURATION, duration), }, fontSize: Constants.TEXT_DEFAULT_FONT_SIZE, fontWeight: Constants.TEXT_DEFAULT_FONT_WEIGHT, diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index a518a7e..df5d5ea 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -1,14 +1,14 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { MediaAssetAnnotatorPage } from '../pages/MediaAssetAnnotatorPage.tsx'; +import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const AppRouter = () => { - const DEMO_URL = import.meta.env.VITE_DEMO_MEDIA_URL; return ( } + element={} /> } /> diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index 11dd50b..4350790 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -8,7 +8,7 @@ export class Constants { /* ============================================================ * Time * ============================================================ */ - static ANNOTATION_MIN_DURATION = 1; + static ANNOTATION_DEFAULT_DURATION = 5; /* ============================================================ * Annotation Types diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts new file mode 100644 index 0000000..cdb9d27 --- /dev/null +++ b/src/utils/runtimeConfig.ts @@ -0,0 +1,20 @@ +export type RuntimeConfig = { + ANNOTATIONS_FETCH_API_URL: string; + MEDIA_ASSET_FETCH_API_URL: string; + DEMO_MEDIA_URL: string; +}; + +declare global { + interface Window { + __RUNTIME_CONFIG__?: RuntimeConfig; + } +} + +export const runtimeConfig: RuntimeConfig = { + ANNOTATIONS_FETCH_API_URL: + window.__RUNTIME_CONFIG__?.ANNOTATIONS_FETCH_API_URL ?? "", + MEDIA_ASSET_FETCH_API_URL: + window.__RUNTIME_CONFIG__?.MEDIA_ASSET_FETCH_API_URL ?? "", + DEMO_MEDIA_URL: + window.__RUNTIME_CONFIG__?.DEMO_MEDIA_URL ?? "", +}; \ No newline at end of file diff --git a/src/utils/videoTime.utils.ts b/src/utils/videoTime.utils.ts index 5ac196a..953f61a 100644 --- a/src/utils/videoTime.utils.ts +++ b/src/utils/videoTime.utils.ts @@ -1,4 +1,3 @@ -import { Constants } from './Constants.ts'; import type { TimeRange } from '../types/intern/annotation.ts'; export const formatTime = (t: number) => { @@ -32,8 +31,7 @@ export function computeNextInterval( export const isValidInterval = (interval: TimeRange, duration: number) => { return ( interval.start >= 0 && - interval.end <= duration && - interval.end - interval.start >= Constants.ANNOTATION_MIN_DURATION + interval.end <= duration ); }; diff --git a/vite.config.ts b/vite.config.ts index da17daf..bfe40e5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,5 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], + base: "./" }); From 25171ee67557c6faed8e6106bca63caab248e630 Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Wed, 4 Feb 2026 17:57:48 +0100 Subject: [PATCH 04/14] [Upd] dockerfile added. Custom configuration for nginx. --- Dockerfile | 8 ++++---- public/runtime-env.js | 8 ++++---- src/api/fetchAnnotations.ts | 2 -- src/router/AppRouter.tsx | 7 ++++++- src/utils/runtimeConfig.ts | 23 ++++++++++------------- src/utils/videoTime.utils.ts | 5 +---- vite.config.ts | 2 +- 7 files changed, 26 insertions(+), 29 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5e3b2dd..c3e0a41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,11 +30,11 @@ RUN rm /etc/nginx/conf.d/default.conf # Copy build output COPY --from=build /app/dist /usr/share/nginx/html -# Custom nginx template -COPY nginx.conf.template /etc/nginx/templates/nginx.conf.template +# Copy nginx config template +COPY .docker/nginx.conf.template /etc/nginx/templates/nginx.conf.template -# Entrypoint -COPY entrypoint.sh /entrypoint.sh +# Copy entrypoint +COPY .docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh # Run as non-root diff --git a/public/runtime-env.js b/public/runtime-env.js index d5b4083..e179f1d 100644 --- a/public/runtime-env.js +++ b/public/runtime-env.js @@ -1,5 +1,5 @@ window.__RUNTIME_CONFIG__ = { - ANNOTATIONS_FETCH_API_URL: "__ANNOTATIONS_FETCH_API_URL__", - MEDIA_ASSET_FETCH_API_URL: "__MEDIA_ASSET_FETCH_API_URL__", - DEMO_MEDIA_URL: "__DEMO_MEDIA_URL__", -}; \ No newline at end of file + ANNOTATIONS_FETCH_API_URL: '__ANNOTATIONS_FETCH_API_URL__', + MEDIA_ASSET_FETCH_API_URL: '__MEDIA_ASSET_FETCH_API_URL__', + DEMO_MEDIA_URL: '__DEMO_MEDIA_URL__', +}; diff --git a/src/api/fetchAnnotations.ts b/src/api/fetchAnnotations.ts index 43704ea..ae6920b 100644 --- a/src/api/fetchAnnotations.ts +++ b/src/api/fetchAnnotations.ts @@ -2,7 +2,6 @@ import type { AnnotationData } from '../types/extern/annotationData.ts'; import { mockAnnotations } from './mocks/annotatios'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; - export const fetchAnnotations = async (mediaAssetId: string): Promise => { if (import.meta.env.DEV) { console.warn('[fetchAnnotations] DEV mode – returning mock data'); @@ -10,7 +9,6 @@ export const fetchAnnotations = async (mediaAssetId: string): Promise { } + element={ + + } /> } /> diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts index cdb9d27..4df5303 100644 --- a/src/utils/runtimeConfig.ts +++ b/src/utils/runtimeConfig.ts @@ -1,20 +1,17 @@ export type RuntimeConfig = { - ANNOTATIONS_FETCH_API_URL: string; - MEDIA_ASSET_FETCH_API_URL: string; - DEMO_MEDIA_URL: string; + ANNOTATIONS_FETCH_API_URL: string; + MEDIA_ASSET_FETCH_API_URL: string; + DEMO_MEDIA_URL: string; }; declare global { - interface Window { - __RUNTIME_CONFIG__?: RuntimeConfig; - } + interface Window { + __RUNTIME_CONFIG__?: RuntimeConfig; + } } export const runtimeConfig: RuntimeConfig = { - ANNOTATIONS_FETCH_API_URL: - window.__RUNTIME_CONFIG__?.ANNOTATIONS_FETCH_API_URL ?? "", - MEDIA_ASSET_FETCH_API_URL: - window.__RUNTIME_CONFIG__?.MEDIA_ASSET_FETCH_API_URL ?? "", - DEMO_MEDIA_URL: - window.__RUNTIME_CONFIG__?.DEMO_MEDIA_URL ?? "", -}; \ No newline at end of file + ANNOTATIONS_FETCH_API_URL: window.__RUNTIME_CONFIG__?.ANNOTATIONS_FETCH_API_URL ?? '', + MEDIA_ASSET_FETCH_API_URL: window.__RUNTIME_CONFIG__?.MEDIA_ASSET_FETCH_API_URL ?? '', + DEMO_MEDIA_URL: window.__RUNTIME_CONFIG__?.DEMO_MEDIA_URL ?? '', +}; diff --git a/src/utils/videoTime.utils.ts b/src/utils/videoTime.utils.ts index 953f61a..65993b2 100644 --- a/src/utils/videoTime.utils.ts +++ b/src/utils/videoTime.utils.ts @@ -29,10 +29,7 @@ export function computeNextInterval( } export const isValidInterval = (interval: TimeRange, duration: number) => { - return ( - interval.start >= 0 && - interval.end <= duration - ); + return interval.start >= 0 && interval.end <= duration; }; export const clamp = (value: number, min: number, max: number) => diff --git a/vite.config.ts b/vite.config.ts index bfe40e5..0b21174 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,5 +4,5 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], - base: "./" + base: './', }); From c81e1a27064999049526b8ce87dbdcac36329f09 Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Thu, 5 Feb 2026 11:30:00 +0100 Subject: [PATCH 05/14] [Upd] dockerfile added. Custom configuration for nginx. --- .docker/entrypoint.sh | 14 ++--- .docker/nginx.conf.template | 28 ---------- .docker/runtime-env.template.js | 6 ++ .env.example | 8 +-- Dockerfile | 13 ++--- index.html | 6 +- package.json | 10 +++- public/runtime-env.js | 7 +-- src/api/fetchAnnotations.ts | 2 +- src/api/fetchMediaAsset.ts | 3 +- src/router/AppRouter.tsx | 7 ++- src/utils/runtimeConfig.ts | 98 ++++++++++++++++++++++++++++++--- vite.config.ts | 3 +- 13 files changed, 131 insertions(+), 74 deletions(-) delete mode 100644 .docker/nginx.conf.template create mode 100644 .docker/runtime-env.template.js diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh index 7c94ee5..4da5a9e 100644 --- a/.docker/entrypoint.sh +++ b/.docker/entrypoint.sh @@ -2,13 +2,11 @@ set -eu envsubst \ - '$ANNOTATIONS_FETCH_API_URL \ - $MEDIA_ASSET_FETCH_API_URL \ - $DEMO_MEDIA_URL' \ - < /usr/share/nginx/html/runtime-env.js \ - > /tmp/runtime-env.js - -mv /tmp/runtime-env.js \ - /usr/share/nginx/html/runtime-env.js + '${ANNOTATIONS_FETCH_API_URL} \ + ${MEDIA_ASSET_FETCH_API_URL} \ + ${DEMO_MEDIA_URL} \ + ${BASE_PATH}' \ + < /etc/nginx/runtime-env.template.js \ + > /var/www/runtime-env.template.js exec "$@" diff --git a/.docker/nginx.conf.template b/.docker/nginx.conf.template deleted file mode 100644 index d094b8b..0000000 --- a/.docker/nginx.conf.template +++ /dev/null @@ -1,28 +0,0 @@ -worker_processes auto; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - sendfile on; - keepalive_timeout 65; - - server { - listen 8080; - - # Base path injected via env, e.g. /annotator - location ${BASE_PATH}/ { - alias /usr/share/nginx/html/; - - index index.html; - - # SPA routing - try_files $uri $uri/ ${BASE_PATH}/index.html; - } - - } -} diff --git a/.docker/runtime-env.template.js b/.docker/runtime-env.template.js new file mode 100644 index 0000000..6e5ff9e --- /dev/null +++ b/.docker/runtime-env.template.js @@ -0,0 +1,6 @@ +window.__RUNTIME_CONFIG__ = { + ANNOTATIONS_FETCH_API_URL: '${ANNOTATIONS_FETCH_API_URL}', + MEDIA_ASSET_FETCH_API_URL: '${MEDIA_ASSET_FETCH_API_URL}', + DEMO_MEDIA_URL: '${DEMO_MEDIA_URL}', + BASE_PATH: '${BASE_PATH}', +}; diff --git a/.env.example b/.env.example index 29b2c84..7a966b6 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -VITE_ANNOTATIONS_FETCH_API_URL=https://api.example.com/annotations -VITE_MEDIA_ASSET_FETCH_API_URL=https://api.example.com/media-assets -VITE_DEMO_MEDIA_URL=https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4 -BASE_PATH= \ No newline at end of file +ANNOTATOR_ANNOTATIONS_FETCH_API_URL=https://api.example.com/annotations +ANNOTATOR_MEDIA_ASSET_FETCH_API_URL=https://api.example.com/media-assets +ANNOTATOR_DEMO_MEDIA_URL=https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4 +ANNOTATOR_BASE_PATH=/annotator \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c3e0a41..4b021ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,21 +24,16 @@ RUN npm run build ######################################## FROM nginx:1.25-alpine -# Remove default nginx config -RUN rm /etc/nginx/conf.d/default.conf - # Copy build output -COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /usr/src/app/dist /var/www -# Copy nginx config template -COPY .docker/nginx.conf.template /etc/nginx/templates/nginx.conf.template +# Copy custom JS config +COPY .docker/runtime-env.template.js /etc/nginx/runtime-env.template.js # Copy entrypoint COPY .docker/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh -# Run as non-root -USER nginx +RUN chmod +x /entrypoint.sh EXPOSE 8080 diff --git a/index.html b/index.html index 93ca88a..a9fc5a6 100644 --- a/index.html +++ b/index.html @@ -2,15 +2,13 @@ - - - + + Media Asset Annotator
- diff --git a/package.json b/package.json index 239e409..6f186a1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,15 @@ "name": "media-asset-annotator", "private": true, "version": "0.0.0", - "type": "module", + "keywords": [ + "react", + "media", + "annotation" + ], + "author": { + "name": "Yevgeniy Ulchenkov", + "email": "ulcheyev@fel.cvut.cz" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/public/runtime-env.js b/public/runtime-env.js index e179f1d..38fdd85 100644 --- a/public/runtime-env.js +++ b/public/runtime-env.js @@ -1,5 +1,2 @@ -window.__RUNTIME_CONFIG__ = { - ANNOTATIONS_FETCH_API_URL: '__ANNOTATIONS_FETCH_API_URL__', - MEDIA_ASSET_FETCH_API_URL: '__MEDIA_ASSET_FETCH_API_URL__', - DEMO_MEDIA_URL: '__DEMO_MEDIA_URL__', -}; +// Replace placeholders with actual runtime configuration values +window.__RUNTIME_CONFIG__ = {}; diff --git a/src/api/fetchAnnotations.ts b/src/api/fetchAnnotations.ts index ae6920b..76c91cc 100644 --- a/src/api/fetchAnnotations.ts +++ b/src/api/fetchAnnotations.ts @@ -3,7 +3,7 @@ import { mockAnnotations } from './mocks/annotatios'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const fetchAnnotations = async (mediaAssetId: string): Promise => { - if (import.meta.env.DEV) { + if (runtimeConfig.USE_MOCK) { console.warn('[fetchAnnotations] DEV mode – returning mock data'); return mockAnnotations; } diff --git a/src/api/fetchMediaAsset.ts b/src/api/fetchMediaAsset.ts index ccc23df..785a302 100644 --- a/src/api/fetchMediaAsset.ts +++ b/src/api/fetchMediaAsset.ts @@ -3,7 +3,8 @@ import { mockVideoMediaAsset } from './mocks/mediaAsset.ts'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const fetchMediaAsset = async (mediaAssetId: string): Promise => { - if (import.meta.env.DEV) { + if (runtimeConfig.USE_MOCK) { + console.warn('[fetchAnnotations] DEV mode – returning mock asset data'); return mockVideoMediaAsset; } diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index e898d1c..0222d21 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -1,21 +1,22 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { MediaAssetAnnotatorPage } from '../pages/MediaAssetAnnotatorPage.tsx'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; +const basePath = import.meta.env.VITE_BASE_PATH ; export const AppRouter = () => { return ( - + } /> - } /> + } /> ); diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts index 4df5303..1d4b2a2 100644 --- a/src/utils/runtimeConfig.ts +++ b/src/utils/runtimeConfig.ts @@ -1,17 +1,97 @@ -export type RuntimeConfig = { - ANNOTATIONS_FETCH_API_URL: string; - MEDIA_ASSET_FETCH_API_URL: string; - DEMO_MEDIA_URL: string; -}; +// Prefix for Vite-exposed variables +const VITE_ENV_PREFIX = "ANNOTATOR_"; + +/** + * Shape of runtime config injected at runtime (e.g. via runtime-env.js) + */ +export interface RuntimeConfig { + DEMO_MEDIA_URL?: string; + ANNOTATIONS_FETCH_API_URL?: string; + MEDIA_ASSET_FETCH_API_URL?: string; + BASE_PATH?: string; +} +/** + * Extend Window type safely + */ declare global { interface Window { __RUNTIME_CONFIG__?: RuntimeConfig; } } -export const runtimeConfig: RuntimeConfig = { - ANNOTATIONS_FETCH_API_URL: window.__RUNTIME_CONFIG__?.ANNOTATIONS_FETCH_API_URL ?? '', - MEDIA_ASSET_FETCH_API_URL: window.__RUNTIME_CONFIG__?.MEDIA_ASSET_FETCH_API_URL ?? '', - DEMO_MEDIA_URL: window.__RUNTIME_CONFIG__?.DEMO_MEDIA_URL ?? '', +/** + * Extract prefixed variables from import.meta.env + */ +function getViteEnv(): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(import.meta.env)) { + if (key.startsWith(VITE_ENV_PREFIX) && typeof value === "string") { + const strippedKey = key.slice(VITE_ENV_PREFIX.length); + result[strippedKey] = value; + } + } + + return result; +} + +/** + * Merge Vite env and runtime env (runtime takes precedence) + */ +const ENV: Record = { + ...getViteEnv(), + ...(window.__RUNTIME_CONFIG__ ?? {}), }; + +/** + * Strongly-typed env accessor + */ +function getEnv( + name: T, + options?: { + defaultValue?: string; + required?: boolean; + } +): string | undefined { + const value = ENV[name]; + + if (value !== undefined) { + return value; + } + + if (options?.defaultValue !== undefined) { + return options.defaultValue; + } + + if (options?.required) { + throw new Error(`Missing required environment variable: ${name}`); + } + + if(import.meta.env.DEV) { + console.warn(`Environment variable "${name}" is not defined. Using undefined.`); + } else { + throw new Error(`Environment variable "${name}" is not defined`); + } +} + +export const runtimeConfig = { + USE_MOCK: import.meta.env.DEV, + + DEMO_MEDIA_URL: getEnv("DEMO_MEDIA_URL", { + defaultValue: + "https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4", + }), + + ANNOTATIONS_FETCH_API_URL: getEnv("ANNOTATIONS_FETCH_API_URL", { + required: false, + }), + + MEDIA_ASSET_FETCH_API_URL: getEnv("MEDIA_ASSET_FETCH_API_URL", { + required: false, + }), + + BASE_PATH: getEnv("BASE_PATH", { + defaultValue: "/", + }), +} as const; diff --git a/vite.config.ts b/vite.config.ts index 0b21174..0b58f99 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,5 +4,6 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], - base: './', + envPrefix: "ANNOTATOR_", + base: process.env.VITE_BASE_PATH || "", }); From d9a596e02f0e449a1ce9b2bbf6253f4c1eb9e269 Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Thu, 5 Feb 2026 12:51:26 +0100 Subject: [PATCH 06/14] [Upd] dockerfile added. Custom configuration for nginx. --- .docker/entrypoint.sh | 2 +- .docker/runtime-env.template.js | 8 ++++---- src/api/fetchMediaAsset.ts | 1 + src/api/mocks/annotatios.ts | 13 ------------ src/router/AppRouter.tsx | 2 +- src/utils/runtimeConfig.ts | 35 +++++++++++++++------------------ vite.config.ts | 4 ++-- 7 files changed, 25 insertions(+), 40 deletions(-) diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh index 4da5a9e..a993267 100644 --- a/.docker/entrypoint.sh +++ b/.docker/entrypoint.sh @@ -7,6 +7,6 @@ envsubst \ ${DEMO_MEDIA_URL} \ ${BASE_PATH}' \ < /etc/nginx/runtime-env.template.js \ - > /var/www/runtime-env.template.js + > /var/www/runtime-env.js exec "$@" diff --git a/.docker/runtime-env.template.js b/.docker/runtime-env.template.js index 6e5ff9e..8b173ab 100644 --- a/.docker/runtime-env.template.js +++ b/.docker/runtime-env.template.js @@ -1,6 +1,6 @@ window.__RUNTIME_CONFIG__ = { - ANNOTATIONS_FETCH_API_URL: '${ANNOTATIONS_FETCH_API_URL}', - MEDIA_ASSET_FETCH_API_URL: '${MEDIA_ASSET_FETCH_API_URL}', - DEMO_MEDIA_URL: '${DEMO_MEDIA_URL}', - BASE_PATH: '${BASE_PATH}', + ANNOTATIONS_FETCH_API_URL: '${ANNOTATIONS_FETCH_API_URL}', + MEDIA_ASSET_FETCH_API_URL: '${MEDIA_ASSET_FETCH_API_URL}', + DEMO_MEDIA_URL: '${DEMO_MEDIA_URL}', + BASE_PATH: '${BASE_PATH}' }; diff --git a/src/api/fetchMediaAsset.ts b/src/api/fetchMediaAsset.ts index 785a302..54f76cb 100644 --- a/src/api/fetchMediaAsset.ts +++ b/src/api/fetchMediaAsset.ts @@ -3,6 +3,7 @@ import { mockVideoMediaAsset } from './mocks/mediaAsset.ts'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const fetchMediaAsset = async (mediaAssetId: string): Promise => { + if (runtimeConfig.USE_MOCK) { console.warn('[fetchAnnotations] DEV mode – returning mock asset data'); return mockVideoMediaAsset; diff --git a/src/api/mocks/annotatios.ts b/src/api/mocks/annotatios.ts index b11455f..d3d3aff 100644 --- a/src/api/mocks/annotatios.ts +++ b/src/api/mocks/annotatios.ts @@ -26,17 +26,4 @@ export const mockAnnotations: AnnotationData[] = [ color: '#00ff00', opacity: 1, }, - { - id: 'a3', - type: 'text', - label: 'My awesome text 3', - points: '0.4875,0.4221105527638191', - timeStart: 0, - timeEnd: 8, - text: 'Hello world', - fontSize: 0.083424, - fontWeight: 200, - color: '#00ff00', - opacity: 1, - }, ]; diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index 0222d21..6ae0d8b 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -1,7 +1,7 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { MediaAssetAnnotatorPage } from '../pages/MediaAssetAnnotatorPage.tsx'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; -const basePath = import.meta.env.VITE_BASE_PATH ; +const basePath = import.meta.env.VITE_BASE_PATH; export const AppRouter = () => { return ( diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts index 1d4b2a2..64dde5d 100644 --- a/src/utils/runtimeConfig.ts +++ b/src/utils/runtimeConfig.ts @@ -1,5 +1,5 @@ // Prefix for Vite-exposed variables -const VITE_ENV_PREFIX = "ANNOTATOR_"; +const VITE_ENV_PREFIX = 'ANNOTATOR_'; /** * Shape of runtime config injected at runtime (e.g. via runtime-env.js) @@ -27,7 +27,7 @@ function getViteEnv(): Record { const result: Record = {}; for (const [key, value] of Object.entries(import.meta.env)) { - if (key.startsWith(VITE_ENV_PREFIX) && typeof value === "string") { + if (key.startsWith(VITE_ENV_PREFIX) && typeof value === 'string') { const strippedKey = key.slice(VITE_ENV_PREFIX.length); result[strippedKey] = value; } @@ -48,17 +48,14 @@ const ENV: Record = { * Strongly-typed env accessor */ function getEnv( - name: T, - options?: { - defaultValue?: string; - required?: boolean; - } -): string | undefined { + name: T, + options?: { + defaultValue?: string; + required?: boolean; + }, +): string { const value = ENV[name]; - if (value !== undefined) { - return value; - } if (options?.defaultValue !== undefined) { return options.defaultValue; @@ -68,8 +65,9 @@ function getEnv( throw new Error(`Missing required environment variable: ${name}`); } - if(import.meta.env.DEV) { + if (import.meta.env.DEV) { console.warn(`Environment variable "${name}" is not defined. Using undefined.`); + return value; } else { throw new Error(`Environment variable "${name}" is not defined`); } @@ -78,20 +76,19 @@ function getEnv( export const runtimeConfig = { USE_MOCK: import.meta.env.DEV, - DEMO_MEDIA_URL: getEnv("DEMO_MEDIA_URL", { - defaultValue: - "https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4", + DEMO_MEDIA_URL: getEnv('DEMO_MEDIA_URL', { + defaultValue: 'https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4', }), - ANNOTATIONS_FETCH_API_URL: getEnv("ANNOTATIONS_FETCH_API_URL", { + ANNOTATIONS_FETCH_API_URL: getEnv('ANNOTATIONS_FETCH_API_URL', { required: false, }), - MEDIA_ASSET_FETCH_API_URL: getEnv("MEDIA_ASSET_FETCH_API_URL", { + MEDIA_ASSET_FETCH_API_URL: getEnv('MEDIA_ASSET_FETCH_API_URL', { required: false, }), - BASE_PATH: getEnv("BASE_PATH", { - defaultValue: "/", + BASE_PATH: getEnv('BASE_PATH', { + defaultValue: '/', }), } as const; diff --git a/vite.config.ts b/vite.config.ts index 0b58f99..4179d15 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,6 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], - envPrefix: "ANNOTATOR_", - base: process.env.VITE_BASE_PATH || "", + envPrefix: 'ANNOTATOR_', + base: process.env.VITE_BASE_PATH || '', }); From 5406cbd11f88fd48a39dcfa5fafb186000455e9a Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Thu, 5 Feb 2026 13:05:33 +0100 Subject: [PATCH 07/14] [Upd] dockerfile added. Custom configuration for nginx. --- .docker/runtime-env.template.js | 2 +- src/api/fetchAnnotations.ts | 3 ++- src/api/fetchMediaAsset.ts | 3 +-- src/utils/runtimeConfig.ts | 39 +++++++++++++++++++++++++++++++-- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.docker/runtime-env.template.js b/.docker/runtime-env.template.js index 8b173ab..9ce42e4 100644 --- a/.docker/runtime-env.template.js +++ b/.docker/runtime-env.template.js @@ -2,5 +2,5 @@ window.__RUNTIME_CONFIG__ = { ANNOTATIONS_FETCH_API_URL: '${ANNOTATIONS_FETCH_API_URL}', MEDIA_ASSET_FETCH_API_URL: '${MEDIA_ASSET_FETCH_API_URL}', DEMO_MEDIA_URL: '${DEMO_MEDIA_URL}', - BASE_PATH: '${BASE_PATH}' + BASE_PATH: '${BASE_PATH}', }; diff --git a/src/api/fetchAnnotations.ts b/src/api/fetchAnnotations.ts index 76c91cc..da16e2a 100644 --- a/src/api/fetchAnnotations.ts +++ b/src/api/fetchAnnotations.ts @@ -3,7 +3,8 @@ import { mockAnnotations } from './mocks/annotatios'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const fetchAnnotations = async (mediaAssetId: string): Promise => { - if (runtimeConfig.USE_MOCK) { + + if (runtimeConfig.USE_MOCK_DATA) { console.warn('[fetchAnnotations] DEV mode – returning mock data'); return mockAnnotations; } diff --git a/src/api/fetchMediaAsset.ts b/src/api/fetchMediaAsset.ts index 54f76cb..eb667cc 100644 --- a/src/api/fetchMediaAsset.ts +++ b/src/api/fetchMediaAsset.ts @@ -3,8 +3,7 @@ import { mockVideoMediaAsset } from './mocks/mediaAsset.ts'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const fetchMediaAsset = async (mediaAssetId: string): Promise => { - - if (runtimeConfig.USE_MOCK) { + if (runtimeConfig.USE_MOCK_DATA) { console.warn('[fetchAnnotations] DEV mode – returning mock asset data'); return mockVideoMediaAsset; } diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts index 64dde5d..745f7b1 100644 --- a/src/utils/runtimeConfig.ts +++ b/src/utils/runtimeConfig.ts @@ -1,5 +1,6 @@ // Prefix for Vite-exposed variables const VITE_ENV_PREFIX = 'ANNOTATOR_'; +export const APP_MODES = ["demo", "dev", "prod"] as const; /** * Shape of runtime config injected at runtime (e.g. via runtime-env.js) @@ -56,7 +57,6 @@ function getEnv( ): string { const value = ENV[name]; - if (options?.defaultValue !== undefined) { return options.defaultValue; } @@ -73,8 +73,43 @@ function getEnv( } } +function getEnumEnv( + name: string, + allowedValues: T, + options?: { + defaultValue?: T[number]; + required?: boolean; + } +): T[number] { + const raw = getEnv(name, { + defaultValue: options?.defaultValue, + required: options?.required, + }); + + if (!raw) { + if (options?.defaultValue !== undefined) { + return options.defaultValue; + } + throw new Error(`Missing environment variable: ${name}`); + } + + if ((allowedValues as readonly string[]).includes(raw)) { + return raw as T[number]; + } + + throw new Error( + `Invalid value for ${name}: "${raw}". Allowed values: ${allowedValues.join(", ")}` + ); +} + + export const runtimeConfig = { - USE_MOCK: import.meta.env.DEV, + + APP_MODE: getEnumEnv("APP_MODE", APP_MODES, { + defaultValue: import.meta.env.DEV ? "dev" : "prod", + }), + + USE_MOCK_DATA: import.meta.env.DEV || getEnumEnv("APP_MODE", APP_MODES) === "demo", DEMO_MEDIA_URL: getEnv('DEMO_MEDIA_URL', { defaultValue: 'https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4', From 1af22620136a0642c1cb3ca667e4acce43994bdd Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Thu, 5 Feb 2026 13:16:55 +0100 Subject: [PATCH 08/14] [Upd] dockerfile added. Custom configuration for nginx. --- src/api/fetchAnnotations.ts | 1 - src/utils/runtimeConfig.ts | 117 +++++++++++++++--------------------- 2 files changed, 48 insertions(+), 70 deletions(-) diff --git a/src/api/fetchAnnotations.ts b/src/api/fetchAnnotations.ts index da16e2a..ed5ba51 100644 --- a/src/api/fetchAnnotations.ts +++ b/src/api/fetchAnnotations.ts @@ -3,7 +3,6 @@ import { mockAnnotations } from './mocks/annotatios'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; export const fetchAnnotations = async (mediaAssetId: string): Promise => { - if (runtimeConfig.USE_MOCK_DATA) { console.warn('[fetchAnnotations] DEV mode – returning mock data'); return mockAnnotations; diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts index 745f7b1..6a405dd 100644 --- a/src/utils/runtimeConfig.ts +++ b/src/utils/runtimeConfig.ts @@ -1,6 +1,7 @@ // Prefix for Vite-exposed variables const VITE_ENV_PREFIX = 'ANNOTATOR_'; -export const APP_MODES = ["demo", "dev", "prod"] as const; +export const APP_MODES = ['demo', 'dev', 'prod'] as const; +export type AppMode = typeof APP_MODES[number]; /** * Shape of runtime config injected at runtime (e.g. via runtime-env.js) @@ -45,85 +46,63 @@ const ENV: Record = { ...(window.__RUNTIME_CONFIG__ ?? {}), }; -/** - * Strongly-typed env accessor - */ -function getEnv( - name: T, - options?: { - defaultValue?: string; - required?: boolean; - }, -): string { - const value = ENV[name]; - - if (options?.defaultValue !== undefined) { - return options.defaultValue; - } - - if (options?.required) { - throw new Error(`Missing required environment variable: ${name}`); - } +function env(name: string): string | undefined { + return ENV[name]; +} - if (import.meta.env.DEV) { - console.warn(`Environment variable "${name}" is not defined. Using undefined.`); - return value; - } else { - throw new Error(`Environment variable "${name}" is not defined`); - } +function envOrDefault(name: string, value: string): string { + return env(name) ?? value; } -function getEnumEnv( - name: string, - allowedValues: T, - options?: { - defaultValue?: T[number]; - required?: boolean; - } -): T[number] { - const raw = getEnv(name, { - defaultValue: options?.defaultValue, - required: options?.required, - }); - - if (!raw) { - if (options?.defaultValue !== undefined) { - return options.defaultValue; - } - throw new Error(`Missing environment variable: ${name}`); +function envRequired(name: string): string { + const value = env(name); + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); } + return value; +} - if ((allowedValues as readonly string[]).includes(raw)) { - return raw as T[number]; +function parseAppMode(value?: string): AppMode { + if (value === "demo" || value === "dev" || value === "prod") { + return value; } - - throw new Error( - `Invalid value for ${name}: "${raw}". Allowed values: ${allowedValues.join(", ")}` - ); + return import.meta.env.DEV ? "dev" : "demo"; } -export const runtimeConfig = { - - APP_MODE: getEnumEnv("APP_MODE", APP_MODES, { - defaultValue: import.meta.env.DEV ? "dev" : "prod", - }), - USE_MOCK_DATA: import.meta.env.DEV || getEnumEnv("APP_MODE", APP_MODES) === "demo", +// Determine app mode +const APP_MODE: AppMode = parseAppMode(env("APP_MODE")); - DEMO_MEDIA_URL: getEnv('DEMO_MEDIA_URL', { - defaultValue: 'https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4', - }), +const IS_DEMO = APP_MODE === "demo"; +const IS_DEV = APP_MODE === "dev"; +const IS_PROD = APP_MODE === "prod"; - ANNOTATIONS_FETCH_API_URL: getEnv('ANNOTATIONS_FETCH_API_URL', { - required: false, - }), - MEDIA_ASSET_FETCH_API_URL: getEnv('MEDIA_ASSET_FETCH_API_URL', { - required: false, - }), - - BASE_PATH: getEnv('BASE_PATH', { - defaultValue: '/', - }), +// Export runtime config +export const runtimeConfig = { + // mode + APP_MODE, + IS_DEMO, + IS_DEV, + IS_PROD, + + // behavior + USE_MOCK_DATA: IS_DEMO || IS_DEV, + + // always available + BASE_PATH: envOrDefault("BASE_PATH", "/"), + DEMO_MEDIA_URL: envOrDefault( + "DEMO_MEDIA_URL", + "https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4", + ), + + // backend (required only in prod) + ANNOTATIONS_FETCH_API_URL: IS_PROD + ? envRequired("ANNOTATIONS_FETCH_API_URL") + : env("ANNOTATIONS_FETCH_API_URL"), + + MEDIA_ASSET_FETCH_API_URL: IS_PROD + ? envRequired("MEDIA_ASSET_FETCH_API_URL") + : env("MEDIA_ASSET_FETCH_API_URL"), } as const; From 05b7fc2b8ba162988fd3799d913ff4fb48322ded Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Thu, 5 Feb 2026 13:38:33 +0100 Subject: [PATCH 09/14] [Upd] dockerfile added. Custom configuration for nginx. --- .docker/entrypoint.sh | 2 +- Dockerfile | 4 +--- src/router/AppRouter.tsx | 3 +-- src/utils/runtimeConfig.ts | 41 ++++++++++++++++++++------------------ 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh index a993267..22881a4 100644 --- a/.docker/entrypoint.sh +++ b/.docker/entrypoint.sh @@ -7,6 +7,6 @@ envsubst \ ${DEMO_MEDIA_URL} \ ${BASE_PATH}' \ < /etc/nginx/runtime-env.template.js \ - > /var/www/runtime-env.js + > /usr/share/nginx/html/runtime-env.js exec "$@" diff --git a/Dockerfile b/Dockerfile index 4b021ac..0895a85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN npm run build FROM nginx:1.25-alpine # Copy build output -COPY --from=build /usr/src/app/dist /var/www +COPY --from=build /app/dist /usr/share/nginx/html # Copy custom JS config COPY .docker/runtime-env.template.js /etc/nginx/runtime-env.template.js @@ -35,7 +35,5 @@ COPY .docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -EXPOSE 8080 - ENTRYPOINT ["/entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] diff --git a/src/router/AppRouter.tsx b/src/router/AppRouter.tsx index 6ae0d8b..94163d3 100644 --- a/src/router/AppRouter.tsx +++ b/src/router/AppRouter.tsx @@ -1,11 +1,10 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { MediaAssetAnnotatorPage } from '../pages/MediaAssetAnnotatorPage.tsx'; import { runtimeConfig } from '../utils/runtimeConfig.ts'; -const basePath = import.meta.env.VITE_BASE_PATH; export const AppRouter = () => { return ( - + Date: Fri, 6 Feb 2026 13:52:20 +0100 Subject: [PATCH 10/14] [Upd] dockerfile added. Workflow added. --- deploy/.env.example | 36 ++++++++++++++++++++++++ deploy/README.md | 47 ++++++++++++++++++++++++++++++++ deploy/docker-compose.yml | 27 ++++++++++++++++++ deploy/nginx/nginx.conf.template | 21 ++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 deploy/.env.example create mode 100644 deploy/README.md create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/nginx/nginx.conf.template diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..a119ca4 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,36 @@ +# Container names +# Name of the UI container running the Media Asset Annotator application. +# This name is used by the proxy nginx to route requests via proxy_pass. +ANNOTATOR_CONTAINER_NAME=media-asset-annotator + +# Name of the proxy nginx container that exposes the application to the host. +PROXY_CONTAINER_NAME=media-asset-annotator-proxy + + +# External port +# Host machine port on which the application will be accessible. +# Example: http://localhost:2030// +ANNOTATOR_HOST_PORT=2030 + +# Application mode +# Controls application runtime behavior (e.g. dev, demo, prod). +APP_MODE=prod + + +# Base path +# Base URL path under which the application is served. +# Must start with "/" and must be accessed with a trailing slash. +# Example: http://localhost:2030/annotator/ +ANNOTATOR_BASE_PATH=/annotator + + +# Runtime config for the app +# Default media URL used for initial redirect or demo mode +# when no media URL is explicitly provided. +ANNOTATOR_DEMO_MEDIA_URL=https://cdn.pixabay.com/video/2020/08/03/46320-447422988_large.mp4 + +# Backend API endpoint used to fetch annotations. +ANNOTATIONS_FETCH_API_URL=https://api.example.com/annotations + +# Backend API endpoint used to fetch media assets. +MEDIA_ASSET_FETCH_API_URL=https://api.example.com/media diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..c6cfffd --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,47 @@ +# Production Deployment (Docker Compose) + +This document describes how to deploy **Media Asset Annotator** in **production** using **Docker Compose** with environment-based configuration. + +The setup uses: +- one **UI container** (React + nginx, SPA fallback) +- one **proxy nginx container** (base-path routing via `proxy_pass`) +- runtime configuration injected via `.env` + +No rebuild is required when changing base paths or API URLs. + +--- + +## Prerequisites + +- Docker 20+ +- Docker Compose v2+ +- Ports you plan to use are free on the host + +--- + +## Deployment Steps + +### 1️⃣ Create `.env` file + +Copy the example file and adjust values for your environment: + +```bash +cp .env.example .env +``` +Edit .env and fill in all required variables. Descriptions are provided in the file as comments. + +### 2️⃣ Start the stack + +Run Docker Compose with the env file: + +```bash +docker compose --env-file .env up -d +``` +### 3️⃣ Access the application + +Open the application in your browser: + +- `http://://` +⚠️ Trailing slash is mandatory + + diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..7725b33 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,27 @@ +version: "3.9" + +services: + media-asset-annotator: + build: ../ + container_name: ${ANNOTATOR_CONTAINER_NAME:-media-asset-annotator} + expose: + - "80" + environment: + BASE_PATH: "${ANNOTATOR_BASE_PATH:-/annotator}" + DEMO_MEDIA_URL: "${ANNOTATOR_DEMO_MEDIA_URL:-https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4}" + ANNOTATIONS_FETCH_API_URL: "${ANNOTATIONS_FETCH_API_URL:-}" + MEDIA_ASSET_FETCH_API_URL: "${MEDIA_ASSET_FETCH_API_URL:-}" + + nginx: + image: nginx:alpine + container_name: ${PROXY_CONTAINER_NAME:-media-asset-annotator-proxy} + ports: + - "${ANNOTATOR_HOST_PORT:-2030}:80" + environment: + BASE_PATH: "${ANNOTATOR_BASE_PATH:-/annotator}" + ANNOTATOR_CONTAINER_NAME: "${ANNOTATOR_CONTAINER_NAME:-media-asset-annotator}" + volumes: + - ./nginx/nginx.conf.template:/etc/nginx/templates/nginx.conf.template:ro + - /dev/null:/etc/nginx/conf.d/default.conf + depends_on: + - media-asset-annotator diff --git a/deploy/nginx/nginx.conf.template b/deploy/nginx/nginx.conf.template new file mode 100644 index 0000000..245fee7 --- /dev/null +++ b/deploy/nginx/nginx.conf.template @@ -0,0 +1,21 @@ +server { + listen 80; + server_name _; + + # redirect /annotator -> /annotator/ + location = ${BASE_PATH} { + return 302 ${BASE_PATH}/; + } + + # proxy UI + location ${BASE_PATH}/ { + proxy_pass http://${ANNOTATOR_CONTAINER_NAME}/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /health { + return 200; + } +} From 36f786fa6f2c3d6c262e22178ab17b926550c0be Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Fri, 6 Feb 2026 13:53:02 +0100 Subject: [PATCH 11/14] [Upd] dockerfile added. Workflow added. --- .docker/entrypoint.sh | 5 +-- .docker/nginx.conf | 22 +++++++++++++ .docker/runtime-env.template.js | 1 + .github/workflows/build-and-push.yml | 48 ++++++++++++++++++++++++++++ Dockerfile | 7 +++- README.md | 16 ++++++---- deploy/README.md | 7 ++-- deploy/docker-compose.yml | 18 +++++------ src/utils/runtimeConfig.ts | 4 +-- vite.config.ts | 3 +- 10 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 .docker/nginx.conf create mode 100644 .github/workflows/build-and-push.yml diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh index 22881a4..c270d2b 100644 --- a/.docker/entrypoint.sh +++ b/.docker/entrypoint.sh @@ -5,8 +5,9 @@ envsubst \ '${ANNOTATIONS_FETCH_API_URL} \ ${MEDIA_ASSET_FETCH_API_URL} \ ${DEMO_MEDIA_URL} \ - ${BASE_PATH}' \ + ${BASE_PATH} \ + ${APP_MODE}' \ < /etc/nginx/runtime-env.template.js \ - > /usr/share/nginx/html/runtime-env.js + > /var/www/runtime-env.js exec "$@" diff --git a/.docker/nginx.conf b/.docker/nginx.conf new file mode 100644 index 0000000..c554210 --- /dev/null +++ b/.docker/nginx.conf @@ -0,0 +1,22 @@ +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + server { + listen 80; + server_name _; + + root /var/www; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + } +} diff --git a/.docker/runtime-env.template.js b/.docker/runtime-env.template.js index 9ce42e4..bb9d742 100644 --- a/.docker/runtime-env.template.js +++ b/.docker/runtime-env.template.js @@ -3,4 +3,5 @@ window.__RUNTIME_CONFIG__ = { MEDIA_ASSET_FETCH_API_URL: '${MEDIA_ASSET_FETCH_API_URL}', DEMO_MEDIA_URL: '${DEMO_MEDIA_URL}', BASE_PATH: '${BASE_PATH}', + APP_MODE: '${APP_MODE}', }; diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml new file mode 100644 index 0000000..cbd8691 --- /dev/null +++ b/.github/workflows/build-and-push.yml @@ -0,0 +1,48 @@ +name: Build & Push Frontend + +on: + push: + branches: + - main + - dev/release-test + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USER }}/media-asset-annotator + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=dev,enable=${{ startsWith(github.ref, 'refs/heads/dev') }} + type=ref,event=tag + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 0895a85..3a8bb0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN npm run build FROM nginx:1.25-alpine # Copy build output -COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /app/dist /var/www # Copy custom JS config COPY .docker/runtime-env.template.js /etc/nginx/runtime-env.template.js @@ -33,7 +33,12 @@ COPY .docker/runtime-env.template.js /etc/nginx/runtime-env.template.js # Copy entrypoint COPY .docker/entrypoint.sh /entrypoint.sh +# Copy nginx configuration +COPY .docker/nginx.conf /etc/nginx/nginx.conf + RUN chmod +x /entrypoint.sh +EXPOSE 80 + ENTRYPOINT ["/entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 7a0e4d7..613139d 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,13 @@ npm run dev Access the application using one of the following URLs: -- http://localhost:5173/ (default demo) -- http://localhost:5173/annotator?id= -- http://localhost:5173/annotator?url= +- http://localhost:5173 (default demo) +- http://localhost:5173/asset?id= +- http://localhost:5173/asset?url= + +### Run Production Server + +In order to run the application in production mode refer to the [deployment instructions](deploy/README.md). ### Build for Production @@ -96,6 +100,6 @@ npm run preview Access the application using one of the following URLs: -- http://localhost:4173/ (default demo) -- http://localhost:4173/annotator?id= -- http://localhost:4173/annotator?url= +- http://localhost:4173 (default demo) +- http://localhost:4173/asset?id= +- http://localhost:4173/asset?url= diff --git a/deploy/README.md b/deploy/README.md index c6cfffd..de017cb 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -3,6 +3,7 @@ This document describes how to deploy **Media Asset Annotator** in **production** using **Docker Compose** with environment-based configuration. The setup uses: + - one **UI container** (React + nginx, SPA fallback) - one **proxy nginx container** (base-path routing via `proxy_pass`) - runtime configuration injected via `.env` @@ -28,6 +29,7 @@ Copy the example file and adjust values for your environment: ```bash cp .env.example .env ``` + Edit .env and fill in all required variables. Descriptions are provided in the file as comments. ### 2️⃣ Start the stack @@ -37,11 +39,10 @@ Run Docker Compose with the env file: ```bash docker compose --env-file .env up -d ``` + ### 3️⃣ Access the application Open the application in your browser: - `http://://` -⚠️ Trailing slash is mandatory - - + ⚠️ Trailing slash is mandatory diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 7725b33..d8eee9a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,25 +1,25 @@ -version: "3.9" +version: '3.9' services: media-asset-annotator: build: ../ container_name: ${ANNOTATOR_CONTAINER_NAME:-media-asset-annotator} expose: - - "80" + - '80' environment: - BASE_PATH: "${ANNOTATOR_BASE_PATH:-/annotator}" - DEMO_MEDIA_URL: "${ANNOTATOR_DEMO_MEDIA_URL:-https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4}" - ANNOTATIONS_FETCH_API_URL: "${ANNOTATIONS_FETCH_API_URL:-}" - MEDIA_ASSET_FETCH_API_URL: "${MEDIA_ASSET_FETCH_API_URL:-}" + BASE_PATH: '${ANNOTATOR_BASE_PATH:-/annotator}' + DEMO_MEDIA_URL: '${ANNOTATOR_DEMO_MEDIA_URL:-https://cdn.pixabay.com/video/2023/09/15/180693-864967735_large.mp4}' + ANNOTATIONS_FETCH_API_URL: '${ANNOTATIONS_FETCH_API_URL:-}' + MEDIA_ASSET_FETCH_API_URL: '${MEDIA_ASSET_FETCH_API_URL:-}' nginx: image: nginx:alpine container_name: ${PROXY_CONTAINER_NAME:-media-asset-annotator-proxy} ports: - - "${ANNOTATOR_HOST_PORT:-2030}:80" + - '${ANNOTATOR_HOST_PORT:-2030}:80' environment: - BASE_PATH: "${ANNOTATOR_BASE_PATH:-/annotator}" - ANNOTATOR_CONTAINER_NAME: "${ANNOTATOR_CONTAINER_NAME:-media-asset-annotator}" + BASE_PATH: '${ANNOTATOR_BASE_PATH:-/annotator}' + ANNOTATOR_CONTAINER_NAME: '${ANNOTATOR_CONTAINER_NAME:-media-asset-annotator}' volumes: - ./nginx/nginx.conf.template:/etc/nginx/templates/nginx.conf.template:ro - /dev/null:/etc/nginx/conf.d/default.conf diff --git a/src/utils/runtimeConfig.ts b/src/utils/runtimeConfig.ts index 2b2094d..3f9c7ce 100644 --- a/src/utils/runtimeConfig.ts +++ b/src/utils/runtimeConfig.ts @@ -53,7 +53,7 @@ function env(name: string): string | undefined { function envOrDefault(name: string, defaultValue: string): string { const value = env(name); - if (value === undefined || value.trim() === "") { + if (value === undefined || value.trim() === '') { return defaultValue; } @@ -72,7 +72,7 @@ function parseAppMode(value?: string): AppMode { if (value === 'demo' || value === 'dev' || value === 'prod') { return value; } - return import.meta.env.DEV ? 'dev' : 'demo'; + return import.meta.env.DEV ? 'dev' : 'prod'; } // Determine app mode diff --git a/vite.config.ts b/vite.config.ts index 4179d15..40af710 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,5 +5,6 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [react(), tailwindcss()], envPrefix: 'ANNOTATOR_', - base: process.env.VITE_BASE_PATH || '', + base: '', + root: '', }); From 086e70147026cfb889e28a0df47698037017f127 Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Fri, 6 Feb 2026 14:05:05 +0100 Subject: [PATCH 12/14] [Fix] fixed workflow name in badge. --- .github/workflows/build-and-push.yml | 2 +- README.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push.yml b/.github/workflows/build-and-push.yml index cbd8691..3fedf1c 100644 --- a/.github/workflows/build-and-push.yml +++ b/.github/workflows/build-and-push.yml @@ -1,4 +1,4 @@ -name: Build & Push Frontend +name: Build & Push on: push: diff --git a/README.md b/README.md index 613139d..d8e7f53 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ on media assets such as images and video using a canvas-based rendering model. This repository contains the **frontend module** of the Media Asset Annotator system and is designed to work with the **Media Asset Annotator Service** backend. -## [![Vercel Deploy](https://deploy-badge.vercel.app/vercel/media-asset-annotator?style=for-the-badge&name=vercel+demo)](https://media-asset-annotator.vercel.app) +## [![Vercel Deploy](https://deploy-badge.vercel.app/vercel/media-asset-annotator?name=vercel+demo)](https://media-asset-annotator.vercel.app) +![Build (main)](https://github.com/ulcheyev/media-asset-annotator/actions/workflows/build-and-push.yml/badge.svg?branch=main) + --- From d2a5e51403686284d9d5e0df70e20cee595de8b4 Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Fri, 6 Feb 2026 14:05:24 +0100 Subject: [PATCH 13/14] [Fix] fixed workflow name in badge. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d8e7f53..f92759f 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ on media assets such as images and video using a canvas-based rendering model. This repository contains the **frontend module** of the Media Asset Annotator system and is designed to work with the **Media Asset Annotator Service** backend. -## [![Vercel Deploy](https://deploy-badge.vercel.app/vercel/media-asset-annotator?name=vercel+demo)](https://media-asset-annotator.vercel.app) -![Build (main)](https://github.com/ulcheyev/media-asset-annotator/actions/workflows/build-and-push.yml/badge.svg?branch=main) +## [![Vercel Deploy](https://deploy-badge.vercel.app/vercel/media-asset-annotator?name=vercel+demo)](https://media-asset-annotator.vercel.app) +![Build (main)](https://github.com/ulcheyev/media-asset-annotator/actions/workflows/build-and-push.yml/badge.svg?branch=main) --- From b724a39382439c5f1582f7f5ba8952f2db7ed339 Mon Sep 17 00:00:00 2001 From: Yevgeniy Date: Fri, 6 Feb 2026 14:09:32 +0100 Subject: [PATCH 14/14] [Fix] added arm64 platfrom. --- deploy/docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index d8eee9a..6fca3f0 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -2,7 +2,8 @@ version: '3.9' services: media-asset-annotator: - build: ../ +# build: ../ + image: manki1337/media-asset-annotator:latest container_name: ${ANNOTATOR_CONTAINER_NAME:-media-asset-annotator} expose: - '80'