diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e62267829..f9e999991a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## \[Unreleased] ### Added -- TDB +- Now CVAT supports project/task markdown description with additional assets +(png, jpeg, gif, webp images and pdf files) () ### Changed - TDB diff --git a/cvat-core/package.json b/cvat-core/package.json index 4cd658b3df6..b35b9122a9f 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "9.1.3", + "version": "9.2.0", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "src/api.ts", "scripts": { diff --git a/cvat-core/src/api-implementation.ts b/cvat-core/src/api-implementation.ts index 421c7ed5287..8c4ac612e45 100644 --- a/cvat-core/src/api-implementation.ts +++ b/cvat-core/src/api-implementation.ts @@ -25,6 +25,8 @@ import Project from './project'; import CloudStorage from './cloud-storage'; import Organization from './organization'; import Webhook from './webhook'; +import { ArgumentError } from './exceptions'; +import { SerializedAsset } from './server-response-types'; export default function implementAPI(cvat) { cvat.plugins.list.implementation = PluginRegistry.list; @@ -133,6 +135,15 @@ export default function implementAPI(cvat) { return result; }; + cvat.assets.create.implementation = async (file: File, guideId: number): Promise => { + if (!(file instanceof File)) { + throw new ArgumentError('Assets expect a file'); + } + + const result = await serverProxy.assets.create(file, guideId); + return result; + }; + cvat.users.get.implementation = async (filter) => { checkFilter(filter, { id: isInteger, diff --git a/cvat-core/src/api.ts b/cvat-core/src/api.ts index 786598d48c5..5448c61ec16 100644 --- a/cvat-core/src/api.ts +++ b/cvat-core/src/api.ts @@ -3,11 +3,6 @@ // // SPDX-License-Identifier: MIT -/** - * External API which should be used by for development - * @module API - */ - import PluginRegistry from './plugins'; import loggerStorage from './logger-storage'; import { EventLogger } from './log'; @@ -25,6 +20,7 @@ import { FrameData } from './frames'; import CloudStorage from './cloud-storage'; import Organization from './organization'; import Webhook from './webhook'; +import AnnotationGuide from './guide'; import * as enums from './enums'; @@ -147,6 +143,12 @@ function build() { return result; }, }, + assets: { + async create(file: File, guideId: number) { + const result = await PluginRegistry.apiWrapper(cvat.assets.create, file, guideId); + return result; + }, + }, jobs: { async get(filter = {}) { const result = await PluginRegistry.apiWrapper(cvat.jobs.get, filter); @@ -287,6 +289,7 @@ function build() { CloudStorage, Organization, Webhook, + AnnotationGuide, }, }; diff --git a/cvat-core/src/guide.ts b/cvat-core/src/guide.ts new file mode 100644 index 00000000000..fb4a43efdd2 --- /dev/null +++ b/cvat-core/src/guide.ts @@ -0,0 +1,92 @@ +// Copyright (C) 2023 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import { SerializedGuide } from './server-response-types'; +import { ArgumentError, DataError } from './exceptions'; +import PluginRegistry from './plugins'; +import serverProxy from './server-proxy'; + +class AnnotationGuide { + #id: AnnotationGuide['id']; + #taskId: AnnotationGuide['taskId']; + #projectId: AnnotationGuide['projectId']; + #createdDate?: AnnotationGuide['createdDate']; + #updatedDate?: AnnotationGuide['updatedDate']; + #markdown: AnnotationGuide['markdown']; + + constructor(initialData: Partial) { + this.#id = initialData.id; + this.#taskId = initialData.task_id || null; + this.#projectId = initialData.project_id || null; + this.#createdDate = initialData.created_date; + this.#updatedDate = initialData.updated_date; + this.#markdown = initialData.markdown || ''; + } + + public get id(): number | undefined { + return this.#id; + } + + public get taskId(): number | null { + return this.#taskId; + } + + public get projectId(): number | null { + return this.#projectId; + } + + public get createdDate(): string | undefined { + return this.#createdDate; + } + + public get updatedDate(): string | undefined { + return this.#updatedDate; + } + + public get markdown(): string { + return this.#markdown; + } + + public set markdown(value: string) { + if (typeof value !== 'string') { + throw new ArgumentError(`Markdown value must be a string, ${typeof value} received`); + } + this.#markdown = value; + } + + async save(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, AnnotationGuide.prototype.save); + return result; + } +} + +Object.defineProperties(AnnotationGuide.prototype.save, { + implementation: { + writable: false, + enumerable: false, + value: async function implementation(this: AnnotationGuide) { + if (Number.isInteger(this.id)) { + const result = await serverProxy.guides.update(this.id, { markdown: this.markdown }); + return new AnnotationGuide(result); + } + + if (this.projectId === null && this.taskId === null) { + throw new DataError('One of projectId or taskId must be specified for a guide'); + } + + if (this.projectId !== null && this.taskId !== null) { + throw new DataError('Both projectId and taskId must not be presented for a guide'); + } + + const result = await serverProxy.guides.create({ + task_id: this.taskId, + project_id: this.projectId, + markdown: this.markdown, + }); + return new AnnotationGuide(result); + }, + }, +}); + +export default AnnotationGuide; diff --git a/cvat-core/src/project-implementation.ts b/cvat-core/src/project-implementation.ts index 202f56ab701..17e0c953d1e 100644 --- a/cvat-core/src/project-implementation.ts +++ b/cvat-core/src/project-implementation.ts @@ -10,6 +10,7 @@ import Project from './project'; import { exportDataset, importDataset } from './annotations'; import { SerializedLabel } from './server-response-types'; import { Label } from './labels'; +import AnnotationGuide from './guide'; export default function implementProject(projectClass) { projectClass.prototype.save.implementation = async function () { @@ -125,5 +126,14 @@ export default function implementProject(projectClass) { return result; }; + projectClass.prototype.guide.implementation = async function guide() { + if (this.guideId === null) { + return null; + } + + const result = await serverProxy.guides.get(this.guideId); + return new AnnotationGuide(result); + }; + return projectClass; } diff --git a/cvat-core/src/project.ts b/cvat-core/src/project.ts index 92be144179a..71ac6a07a7f 100644 --- a/cvat-core/src/project.ts +++ b/cvat-core/src/project.ts @@ -12,6 +12,7 @@ import { ArgumentError } from './exceptions'; import { Label } from './labels'; import User from './user'; import { FieldUpdateTrigger } from './common'; +import AnnotationGuide from './guide'; export default class Project { public readonly id: number; @@ -19,6 +20,7 @@ export default class Project { public assignee: User; public bugTracker: string; public readonly status: ProjectStatus; + public readonly guideId: number | null; public readonly organization: string | null; public readonly owner: User; public readonly createdDate: string; @@ -39,6 +41,7 @@ export default class Project { name: undefined, status: undefined, assignee: undefined, + guide_id: undefined, organization: undefined, owner: undefined, bug_tracker: undefined, @@ -98,6 +101,9 @@ export default class Project { owner: { get: () => data.owner, }, + guideId: { + get: () => data.guide_id, + }, organization: { get: () => data.organization, }, @@ -230,6 +236,11 @@ export default class Project { const result = await PluginRegistry.apiWrapper.call(this, Project.restore, storage, file); return result; } + + async guide(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, Project.prototype.guide); + return result; + } } Object.defineProperties( diff --git a/cvat-core/src/server-proxy.ts b/cvat-core/src/server-proxy.ts index ec692db17aa..7fed70d8b33 100644 --- a/cvat-core/src/server-proxy.ts +++ b/cvat-core/src/server-proxy.ts @@ -11,7 +11,7 @@ import { SerializedLabel, SerializedAnnotationFormats, ProjectsFilter, SerializedProject, SerializedTask, TasksFilter, SerializedUser, SerializedAbout, SerializedRemoteFile, SerializedUserAgreement, - SerializedRegister, JobsFilter, SerializedJob, + SerializedRegister, JobsFilter, SerializedJob, SerializedGuide, SerializedAsset, } from 'server-response-types'; import { Storage } from './storage'; import { StorageLocation, WebhookSourceType } from './enums'; @@ -2167,6 +2167,57 @@ async function receiveWebhookEvents(type: WebhookSourceType): Promise } } +async function getGuide(id: number): Promise { + const { backendAPI } = config; + + try { + const response = await Axios.get(`${backendAPI}/guides/${id}`); + return response.data; + } catch (errorData) { + throw generateError(errorData); + } +} + +async function createGuide(data: Partial): Promise { + const { backendAPI } = config; + + try { + const response = await Axios.post(`${backendAPI}/guides`, data); + return response.data; + } catch (errorData) { + throw generateError(errorData); + } +} + +async function updateGuide(id: number, data: Partial): Promise { + const { backendAPI } = config; + + try { + const response = await Axios.patch(`${backendAPI}/guides/${id}`, data); + return response.data; + } catch (errorData) { + throw generateError(errorData); + } +} + +async function createAsset(file: File, guideId: number): Promise { + const { backendAPI } = config; + const form = new FormData(); + form.append('file', file); + form.append('guide_id', guideId); + + try { + const response = await Axios.post(`${backendAPI}/assets`, form, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; + } catch (errorData) { + throw generateError(errorData); + } +} + export default Object.freeze({ server: Object.freeze({ setAuthData, @@ -2310,4 +2361,14 @@ export default Object.freeze({ ping: pingWebhook, events: receiveWebhookEvents, }), + + guides: Object.freeze({ + get: getGuide, + create: createGuide, + update: updateGuide, + }), + + assets: Object.freeze({ + create: createAsset, + }), }); diff --git a/cvat-core/src/server-response-types.ts b/cvat-core/src/server-response-types.ts index c0d80eda159..021f0ae93ac 100644 --- a/cvat-core/src/server-response-types.ts +++ b/cvat-core/src/server-response-types.ts @@ -61,6 +61,7 @@ export interface SerializedProject { dimension: DimensionType; name: string; organization: number | null; + guide_id: number | null; owner: SerializedUser; source_storage: { id: number; location: 'local' | 'cloud'; cloud_storage_id: null }; target_storage: { id: number; location: 'local' | 'cloud'; cloud_storage_id: null }; @@ -94,6 +95,7 @@ export interface SerializedTask { overlap: number | null; owner: SerializedUser; project_id: number | null; + guide_id: number | null; segment_size: number; size: number; source_storage: { id: number; location: 'local' | 'cloud'; cloud_storage_id: null }; @@ -115,10 +117,11 @@ export interface SerializedJob { labels: { count: number; url: string }; mode: TaskMode; project_id: number | null; + guide_id: number | null; stage: JobStage; state: JobState; - startFrame: number; - stopFrame: number; + start_frame: number; + stop_frame: number; task_id: number; updated_date: string; url: string; @@ -174,3 +177,21 @@ export interface SerializedRegister { last_name: string; username: string; } + +export interface SerializedGuide { + id?: number; + task_id: number | null; + project_id: number | null; + owner: SerializedUser; + created_date: string; + updated_date: string; + markdown: string; +} + +export interface SerializedAsset { + uuid?: string; + guide?: number; + filename: string; + created_date: string; + owner: SerializedUser; +} diff --git a/cvat-core/src/session-implementation.ts b/cvat-core/src/session-implementation.ts index bdde1c7c21d..24bbc1665af 100644 --- a/cvat-core/src/session-implementation.ts +++ b/cvat-core/src/session-implementation.ts @@ -34,6 +34,7 @@ import { freezeHistory, clearActions, getActions, clearCache, getHistory, } from './annotations'; +import AnnotationGuide from './guide'; // must be called with task/job context async function deleteFrameWrapper(jobID, frame) { @@ -371,6 +372,15 @@ export function implementJob(Job) { return this; }; + Job.prototype.guide.implementation = async function guide() { + if (this.guideId === null) { + return null; + } + + const result = await serverProxy.guides.get(this.guideId); + return new AnnotationGuide(result); + }; + return Job; } @@ -810,5 +820,14 @@ export function implementTask(Task) { return result; }; + Task.prototype.guide.implementation = async function guide() { + if (this.guideId === null) { + return null; + } + + const result = await serverProxy.guides.get(this.guideId); + return new AnnotationGuide(result); + }; + return Task; } diff --git a/cvat-core/src/session.ts b/cvat-core/src/session.ts index 4a01202de8c..0e8d8e4226b 100644 --- a/cvat-core/src/session.ts +++ b/cvat-core/src/session.ts @@ -15,6 +15,8 @@ import { ArgumentError } from './exceptions'; import { Label } from './labels'; import User from './user'; import { FieldUpdateTrigger } from './common'; +import { SerializedJob, SerializedTask } from './server-response-types'; +import AnnotationGuide from './guide'; function buildDuplicatedAPI(prototype) { Object.defineProperties(prototype, { @@ -312,6 +314,7 @@ export class Job extends Session { public readonly startFrame: number; public readonly stopFrame: number; public readonly projectId: number | null; + public readonly guideId: number | null; public readonly taskId: number; public readonly dimension: DimensionType; public readonly dataCompressedChunkType: ChunkType; @@ -361,7 +364,7 @@ export class Job extends Session { log: CallableFunction; }; - constructor(initialData) { + constructor(initialData: SerializedJob) { super(); const data = { id: undefined, @@ -370,7 +373,8 @@ export class Job extends Session { state: undefined, start_frame: undefined, stop_frame: undefined, - project_id: null, + project_id: undefined, + guide_id: null, task_id: undefined, labels: [], dimension: undefined, @@ -476,6 +480,9 @@ export class Job extends Session { projectId: { get: () => data.project_id, }, + guideId: { + get: () => data.guide_id, + }, taskId: { get: () => data.task_id, }, @@ -559,6 +566,11 @@ export class Job extends Session { return result; } + async guide(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.guide); + return result; + } + async openIssue(issue, message) { const result = await PluginRegistry.apiWrapper.call(this, Job.prototype.openIssue, issue, message); return result; @@ -577,6 +589,7 @@ export class Task extends Session { public bugTracker: string; public subset: string; public labels: Label[]; + public readonly guideId: number | null; public readonly id: number; public readonly status: TaskStatus; public readonly size: number; @@ -648,13 +661,14 @@ export class Task extends Session { log: CallableFunction; }; - constructor(initialData) { + constructor(initialData: SerializedTask) { super(); const data = { id: undefined, name: undefined, project_id: null, + guide_id: undefined, status: undefined, size: undefined, mode: undefined, @@ -729,6 +743,9 @@ export class Task extends Session { stage: job.stage, start_frame: job.start_frame, stop_frame: job.stop_frame, + guide_id: job.guide_id, + issues: job.issues, + updated_date: job.updated_date, // following fields also returned when doing API request /jobs/ // here we know them from task and append to constructor @@ -773,6 +790,9 @@ export class Task extends Session { data.project_id = projectId; }, }, + guideId: { + get: () => data.guide_id, + }, status: { get: () => data.status, }, @@ -1085,6 +1105,11 @@ export class Task extends Session { const result = await PluginRegistry.apiWrapper.call(this, Task.restore, storage, file); return result; } + + async guide(): Promise { + const result = await PluginRegistry.apiWrapper.call(this, Task.prototype.guide); + return result; + } } buildDuplicatedAPI(Job.prototype); diff --git a/cvat-core/tests/mocks/dummy-data.mock.js b/cvat-core/tests/mocks/dummy-data.mock.js index 84e6b3883b6..9e379209825 100644 --- a/cvat-core/tests/mocks/dummy-data.mock.js +++ b/cvat-core/tests/mocks/dummy-data.mock.js @@ -852,6 +852,7 @@ const jobsDummyData = { results: [ { id: 112, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -863,6 +864,7 @@ const jobsDummyData = { }, { id: 100, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -874,6 +876,7 @@ const jobsDummyData = { }, { id: 40, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -885,6 +888,7 @@ const jobsDummyData = { }, { id: 111, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -896,6 +900,7 @@ const jobsDummyData = { }, { id: 110, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -907,6 +912,7 @@ const jobsDummyData = { }, { id: 109, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -918,6 +924,7 @@ const jobsDummyData = { }, { id: 108, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -929,6 +936,7 @@ const jobsDummyData = { }, { id: 107, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -940,6 +948,7 @@ const jobsDummyData = { }, { id: 106, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -951,6 +960,7 @@ const jobsDummyData = { }, { id: 105, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -962,6 +972,7 @@ const jobsDummyData = { }, { id: 104, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -973,6 +984,7 @@ const jobsDummyData = { }, { id: 103, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -984,6 +996,7 @@ const jobsDummyData = { }, { id: 102, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -995,6 +1008,7 @@ const jobsDummyData = { }, { id: 101, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -1006,6 +1020,7 @@ const jobsDummyData = { }, { id: 9, + guide_id: null, assignee: null, status: 'completed', stage: 'acceptance', @@ -1017,6 +1032,7 @@ const jobsDummyData = { }, { id: 8, + guide_id: null, assignee: null, status: 'completed', stage: 'acceptance', @@ -1028,6 +1044,7 @@ const jobsDummyData = { }, { id: 7, + guide_id: null, assignee: null, status: 'completed', stage: 'acceptance', @@ -1039,6 +1056,7 @@ const jobsDummyData = { }, { id: 6, + guide_id: null, assignee: null, status: 'completed', stage: 'acceptance', @@ -1050,6 +1068,7 @@ const jobsDummyData = { }, { id: 5, + guide_id: null, assignee: null, status: 'completed', stage: 'acceptance', @@ -1061,6 +1080,7 @@ const jobsDummyData = { }, { id: 4, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -1072,6 +1092,7 @@ const jobsDummyData = { }, { id: 3, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -1083,6 +1104,7 @@ const jobsDummyData = { }, { id: 2, + guide_id: null, assignee: null, status: 'annotation', stage: 'annotation', @@ -1094,6 +1116,7 @@ const jobsDummyData = { }, { id: 1, + guide_id: null, assignee: null, status: 'annotation', stage: "annotation", @@ -1114,6 +1137,7 @@ const projectsDummyData = { { url: 'http://localhost:7000/api/projects/6', id: 6, + guide_id: null, name: 'Some empty project', labels: { count: projectsDummyLabelsData[6].length, url: 'http://localhost:7000/api/labels?project_id=6' }, tasks: [], @@ -1135,6 +1159,7 @@ const projectsDummyData = { { url: 'http://localhost:7000/api/projects/1', id: 2, + guide_id: null, name: 'Test project with roads', labels: { count: projectsDummyLabelsData[2].length, url: 'http://localhost:7000/api/labels?project_id=2' }, tasks: [ @@ -1150,6 +1175,7 @@ const projectsDummyData = { username: 'admin', }, assignee: null, + guide_id: null, bug_tracker: '', created_date: '2020-10-12T08:59:59.878083Z', updated_date: '2020-10-18T21:02:20.831294Z', @@ -1205,6 +1231,7 @@ const tasksDummyData = { { url: 'http://localhost:7000/api/tasks/102', id: 102, + guide_id: null, name: 'Test', size: 1, mode: 'annotation', @@ -1237,6 +1264,7 @@ const tasksDummyData = { { url: 'http://localhost:7000/api/tasks/100', id: 100, + guide_id: null, name: 'Image Task', size: 9, mode: 'annotation', @@ -1269,6 +1297,7 @@ const tasksDummyData = { { url: 'http://localhost:7000/api/tasks/10', id: 101, + guide_id: null, name: 'Video Task', size: 5002, mode: 'interpolation', @@ -1301,6 +1330,7 @@ const tasksDummyData = { { url: 'http://localhost:7000/api/tasks/40', id: 40, + guide_id: null, name: 'test', project_id: null, mode: 'annotation', @@ -1339,6 +1369,7 @@ const tasksDummyData = { { url: 'http://localhost:7000/api/tasks/3', id: 3, + guide_id: null, name: 'Test Task', size: 5002, mode: 'interpolation', @@ -1368,6 +1399,7 @@ const tasksDummyData = { { url: 'http://localhost:7000/api/tasks/2', id: 2, + guide_id: null, name: 'Video', size: 75, mode: 'interpolation', @@ -1398,6 +1430,7 @@ const tasksDummyData = { { url: 'http://localhost:7000/api/tasks/1', id: 1, + guide_id: null, name: 'Labels Set', size: 9, mode: 'annotation', diff --git a/cvat-ui/package.json b/cvat-ui/package.json index c9dac14e604..d9b0d25cf33 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.51.2", + "version": "1.52.0", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { @@ -34,6 +34,7 @@ "@types/react-share": "^3.0.3", "@types/redux-logger": "^3.0.9", "@types/resize-observer-browser": "^0.1.6", + "@uiw/react-md-editor": "^3.22.0", "antd": "~4.18.9", "copy-to-clipboard": "^3.3.1", "cvat-canvas": "link:./../cvat-canvas", diff --git a/cvat-ui/src/assets/filter-icon.svg b/cvat-ui/src/assets/filter-icon.svg new file mode 100644 index 00000000000..d11d1462bd0 --- /dev/null +++ b/cvat-ui/src/assets/filter-icon.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/cvat-ui/src/assets/fullscreen-icon.svg b/cvat-ui/src/assets/fullscreen-icon.svg index e620d72cdf9..b5e6a28e9ea 100644 --- a/cvat-ui/src/assets/fullscreen-icon.svg +++ b/cvat-ui/src/assets/fullscreen-icon.svg @@ -1 +1,7 @@ - + + + + diff --git a/cvat-ui/src/assets/guide-icon.svg b/cvat-ui/src/assets/guide-icon.svg new file mode 100644 index 00000000000..04b8247c488 --- /dev/null +++ b/cvat-ui/src/assets/guide-icon.svg @@ -0,0 +1,7 @@ + + + + diff --git a/cvat-ui/src/assets/info-icon.svg b/cvat-ui/src/assets/info-icon.svg index 96ca1120b01..2a95977a9eb 100644 --- a/cvat-ui/src/assets/info-icon.svg +++ b/cvat-ui/src/assets/info-icon.svg @@ -1 +1,15 @@ - + + + + + + + + + + + + diff --git a/cvat-ui/src/assets/object-filter-icon.svg b/cvat-ui/src/assets/object-filter-icon.svg deleted file mode 100644 index 7ab2b729b33..00000000000 --- a/cvat-ui/src/assets/object-filter-icon.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/cvat-ui/src/components/annotation-page/styles.scss b/cvat-ui/src/components/annotation-page/styles.scss index 7b00ff9acb9..e611f404596 100644 --- a/cvat-ui/src/components/annotation-page/styles.scss +++ b/cvat-ui/src/components/annotation-page/styles.scss @@ -57,7 +57,7 @@ } > span[role='img'] { - font-size: 24px; + font-size: 20px; } &:hover > span[role='img'] { @@ -102,7 +102,7 @@ margin-right: 10px; > span { - font-size: 25px; + font-size: 20px; margin: 0 4px; color: $player-buttons-color; @@ -119,9 +119,10 @@ .cvat-player-controls { height: 100%; line-height: 27px; + padding-top: 16px; > div { - height: 50%; + height: 25%; } } diff --git a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx index 9054ea023e7..aa3d244a32b 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/player-navigation.tsx @@ -1,4 +1,5 @@ // Copyright (C) 2020-2022 Intel Corporation +// Copyright (C) 2023 CVAT.ai Corporation // // SPDX-License-Identifier: MIT @@ -10,11 +11,11 @@ import Slider from 'antd/lib/slider'; import InputNumber from 'antd/lib/input-number'; import Input from 'antd/lib/input'; import Text from 'antd/lib/typography/Text'; +import Modal from 'antd/lib/modal'; import { RestoreIcon } from 'icons'; import CVATTooltip from 'components/common/cvat-tooltip'; import { clamp } from 'utils/math'; -import modal from 'antd/lib/modal'; interface Props { startFrame: number; @@ -64,7 +65,7 @@ function PlayerNavigation(props: Props): JSX.Element { const showDeleteFrameDialog = useCallback(() => { if (!playing) { switchNavigationBlocked(true); - modal.confirm({ + Modal.confirm({ title: `Do you want to delete frame #${frameNumber}?`, content: 'The frame will not be visible in navigation and exported datasets, but it still can be restored with all the annotations.', className: 'cvat-modal-delete-frame', diff --git a/cvat-ui/src/components/annotation-page/top-bar/right-group.tsx b/cvat-ui/src/components/annotation-page/top-bar/right-group.tsx index ab826dc1e60..1b19ed80be6 100644 --- a/cvat-ui/src/components/annotation-page/top-bar/right-group.tsx +++ b/cvat-ui/src/components/annotation-page/top-bar/right-group.tsx @@ -4,16 +4,22 @@ // SPDX-License-Identifier: MIT import React from 'react'; +import { useSelector } from 'react-redux'; import { Col } from 'antd/lib/grid'; import Icon from '@ant-design/icons'; import Select from 'antd/lib/select'; import Button from 'antd/lib/button'; -import { useSelector } from 'react-redux'; +import Modal from 'antd/lib/modal'; +import notification from 'antd/lib/notification'; -import { FilterIcon, FullscreenIcon, InfoIcon } from 'icons'; +import { + FilterIcon, FullscreenIcon, GuideIcon, InfoIcon, +} from 'icons'; import { DimensionType } from 'cvat-core-wrapper'; import { CombinedState, Workspace } from 'reducers'; +import MDEditor from '@uiw/react-md-editor'; + interface Props { workspace: Workspace; showStatistics(): void; @@ -51,6 +57,42 @@ function RightGroup(props: Props): JSX.Element { Fullscreen + { jobInstance.guideId !== null && ( + + )} + Back + + ); +} + +export default React.memo(GoBackButton); diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index 0435afd7dd6..edebf1fbb20 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -56,6 +56,8 @@ import WebhooksPage from 'components/webhooks-page/webhooks-page'; import CreateWebhookPage from 'components/setup-webhook-pages/create-webhook-page'; import UpdateWebhookPage from 'components/setup-webhook-pages/update-webhook-page'; +import GuidePage from 'components/md-guide/guide-page'; + import AnnotationPageContainer from 'containers/annotation-page/annotation-page'; import { getCore } from 'cvat-core-wrapper'; import GlobalHotKeys, { KeyMap } from 'utils/mousetrap-react'; @@ -458,9 +460,11 @@ class CVATApplication extends React.PureComponent + + diff --git a/cvat-ui/src/components/md-guide/guide-page.tsx b/cvat-ui/src/components/md-guide/guide-page.tsx new file mode 100644 index 00000000000..99ab9481721 --- /dev/null +++ b/cvat-ui/src/components/md-guide/guide-page.tsx @@ -0,0 +1,192 @@ +// Copyright (C) 2023 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; + +import React, { + useState, useEffect, useRef, useCallback, +} from 'react'; +import { useLocation, useParams } from 'react-router'; +import { Row, Col } from 'antd/lib/grid'; +import notification from 'antd/lib/notification'; +import Button from 'antd/lib/button'; +import Space from 'antd/lib/space'; +import MDEditor, { commands } from '@uiw/react-md-editor'; + +import { + getCore, Task, Project, AnnotationGuide, +} from 'cvat-core-wrapper'; +import { useIsMounted } from 'utils/hooks'; +import CVATLoadingSpinner from 'components/common/loading-spinner'; +import GoBackButton from 'components/common/go-back-button'; + +const core = getCore(); + +function GuidePage(): JSX.Element { + const mdEditorRef = useRef(null); + const location = useLocation(); + const isMounted = useIsMounted(); + const [value, setValue] = useState(''); + const instanceType = location.pathname.includes('projects') ? 'project' : 'task'; + const id = +useParams<{ id: string }>().id; + const [guide, setGuide] = useState( + new AnnotationGuide({ + ...(instanceType === 'project' ? { project_id: id } : { task_id: id }), + markdown: value, + }), + ); + const [fetching, setFetching] = useState(true); + + useEffect(() => { + const promise = instanceType === 'project' ? core.projects.get({ id }) : core.tasks.get({ id }); + promise.then(([instance]: [Task | Project]) => ( + instance.guide() + )).then(async (guideInstance: AnnotationGuide | null) => { + if (!guideInstance) { + const createdGuide = await guide.save(); + return createdGuide; + } + + return guideInstance; + }).then((guideInstance: AnnotationGuide) => { + if (isMounted()) { + setValue(guideInstance.markdown); + setGuide(guideInstance); + } + }).catch((error: any) => { + if (isMounted()) { + notification.error({ + message: `Could not receive guide for the ${instanceType} ${id}`, + description: error.toString(), + }); + } + }).finally(() => { + if (isMounted()) { + setFetching(false); + } + }); + }, []); + + const submit = useCallback((currentValue: string) => { + guide.markdown = currentValue; + setFetching(true); + guide.save().then((result: AnnotationGuide) => { + if (isMounted()) { + setValue(result.markdown); + setGuide(result); + } + }).catch((error: any) => { + if (isMounted()) { + notification.error({ + message: 'Could not save guide on the server', + description: error.toString(), + }); + } + }).finally(() => { + if (isMounted()) { + setFetching(false); + } + }); + }, [guide, fetching]); + + const handleInsert = async (event: React.ClipboardEvent | React.DragEvent, files: FileList): Promise => { + if (files.length && guide.id) { + event.preventDefault(); + const assetsToAdd = Array.from(files); + const addedAssets: [File, string][] = []; + + if (mdEditorRef.current) { + const { textArea } = mdEditorRef.current.commandOrchestrator; + const { selectionStart, selectionEnd } = textArea; + const computeNewValue = (): string => { + const addedStrings = addedAssets.map(([file, uuid]) => { + if (file.type.startsWith('image/')) { + return (`![image](/api/assets/${uuid})`); + } + return (`[${file.name}](/api/assets/${uuid})`); + }); + + const stringsToAdd = assetsToAdd.map((file: File) => { + if (file.type.startsWith('image/')) { + return '![image](Loading...)'; + } + return `![${file.name}](Loading...)`; + }); + + return `${value.slice(0, selectionStart)}\n${addedStrings.concat(stringsToAdd).join('\n')}\n${value.slice(selectionEnd)}`; + }; + + setValue(computeNewValue()); + let file = assetsToAdd.shift(); + while (file) { + try { + const { uuid } = await core.assets.create(file, guide.id); + addedAssets.push([file, uuid]); + setValue(computeNewValue()); + } catch (error: any) { + notification.error({ + message: 'Could not create a server asset', + description: error.toString(), + }); + } finally { + file = assetsToAdd.shift(); + } + } + + const finalValue = computeNewValue(); + setValue(finalValue); + submit(finalValue); + } + } + }; + + return ( + + { fetching && } + +
+ +
+
+ { + setValue(val || ''); + }} + onPaste={async (event: React.ClipboardEvent) => { + const { clipboardData } = event; + const { files } = clipboardData; + handleInsert(event, files); + }} + onDrop={async (event: React.DragEvent) => { + const { dataTransfer } = event; + const { files } = dataTransfer; + handleInsert(event, files); + }} + style={{ whiteSpace: 'pre-wrap' }} + /> +
+ + + + +
+ ); +} + +export default React.memo(GuidePage); diff --git a/cvat-ui/src/components/md-guide/md-guide-control.tsx b/cvat-ui/src/components/md-guide/md-guide-control.tsx new file mode 100644 index 00000000000..ee91a2e844a --- /dev/null +++ b/cvat-ui/src/components/md-guide/md-guide-control.tsx @@ -0,0 +1,39 @@ +// Copyright (C) 2023 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +import './styles.scss'; + +import React from 'react'; +import { useHistory } from 'react-router'; +import { Row, Col } from 'antd/lib/grid'; +import Text from 'antd/lib/typography/Text'; +import Button from 'antd/lib/button'; + +interface Props { + instanceType: 'task' | 'project'; + id: number; +} + +function MdGuideControl(props: Props): JSX.Element { + const { instanceType, id } = props; + const history = useHistory(); + + return ( + + + {`${instanceType[0].toUpperCase()}${instanceType.slice(1)} description`} +
+ + +
+ ); +} + +export default React.memo(MdGuideControl); diff --git a/cvat-ui/src/components/md-guide/styles.scss b/cvat-ui/src/components/md-guide/styles.scss new file mode 100644 index 00000000000..ee6a8ce3929 --- /dev/null +++ b/cvat-ui/src/components/md-guide/styles.scss @@ -0,0 +1,38 @@ +// Copyright (C) 2023 CVAT.ai Corporation +// +// SPDX-License-Identifier: MIT + +@import '../../base.scss'; + +.cvat-md-guide-control-wrapper { + margin-top: $grid-unit-size; + + button { + margin-top: $grid-unit-size; + } +} + +.cvat-guide-page { + height: 100%; + + > div { + display: flex; + flex-direction: column; + height: 100%; + + .cvat-guide-page-top { + margin-top: $grid-unit-size * 2; + margin-bottom: $grid-unit-size * 2; + } + + .cvat-guide-page-editor-wrapper { + flex: 1; + } + + .cvat-guide-page-bottom { + margin-top: $grid-unit-size * 2; + margin-bottom: $grid-unit-size * 2; + justify-content: flex-end; + } + } +} diff --git a/cvat-ui/src/components/project-page/details.tsx b/cvat-ui/src/components/project-page/details.tsx index 30481044877..24d29a10f99 100644 --- a/cvat-ui/src/components/project-page/details.tsx +++ b/cvat-ui/src/components/project-page/details.tsx @@ -13,6 +13,7 @@ import { getCore, Project } from 'cvat-core-wrapper'; import LabelsEditor from 'components/labels-editor/labels-editor'; import BugTrackerEditor from 'components/task-page/bug-tracker-editor'; import UserSelector from 'components/task-page/user-selector'; +import MdGuideControl from 'components/md-guide/md-guide-control'; const core = getCore(); @@ -51,6 +52,7 @@ export default function DetailsComponent(props: DetailsComponentProps): JSX.Elem {project.owner ? ` by ${project.owner.username}` : null} {` on ${moment(project.createdDate).format('MMMM Do YYYY')}`} + { diff --git a/cvat-ui/src/components/project-page/project-page.tsx b/cvat-ui/src/components/project-page/project-page.tsx index 5cf8aa92df9..c0764c2af39 100644 --- a/cvat-ui/src/components/project-page/project-page.tsx +++ b/cvat-ui/src/components/project-page/project-page.tsx @@ -60,7 +60,7 @@ export default function ProjectPageComponent(): JSX.Element { const [updatingProject, setUpdatingProject] = useState(false); const mounted = useRef(false); - const taskNamePlugins = useSelector((state: CombinedState) => state.plugins.components.taskItem.name); + const ribbonPlugins = useSelector((state: CombinedState) => state.plugins.components.taskItem.ribbon); const deletes = useSelector((state: CombinedState) => state.projects.activities.deletes); const taskDeletes = useSelector((state: CombinedState) => state.tasks.activities.deletes); const tasksActiveInferences = useSelector((state: CombinedState) => state.models.inferences); @@ -153,10 +153,10 @@ export default function ProjectPageComponent(): JSX.Element { .map((task: Task) => (