Merge branch 'zh' into 'main'

Zh

See merge request prism/lable/labelimage!9
This commit is contained in:
刘耀勇
2026-04-10 14:00:10 +08:00
14 changed files with 1651 additions and 1129 deletions

View File

@@ -2,6 +2,7 @@
import "@mantine/core/styles.css"
import "@mantine/core/styles.layer.css"
import "@mantine/dates/styles.css"
import "@mantine/notifications/styles.css"
import "mantine-datatable/styles.css"
import Script from "next/script"

View File

@@ -405,6 +405,7 @@ export default function OwnTaskTableContainer(props: {
/>
<SettingDataTable<Task.DataProps>
width="100%"
minHeight={0}
style={{ width: "100%", flex: 1 }}
pinFirstColumn
pinLastColumn

View File

@@ -32,6 +32,7 @@ import {
Text,
TextInput,
} from "@mantine/core"
import { DatePickerInput, type DatesRangeValue } from "@mantine/dates"
import { modals } from "@mantine/modals"
import { notifications } from "@mantine/notifications"
import {
@@ -41,8 +42,9 @@ import {
IconSearch,
} from "@tabler/icons-react"
import dayjs from "dayjs"
import "dayjs/locale/zh-cn"
import duration from "dayjs/plugin/duration"
import { DataTableColumn } from "mantine-datatable"
import { DataTableColumn, type DataTableSortStatus } from "mantine-datatable"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
import * as XLSX from "xlsx-js-style"
@@ -60,6 +62,8 @@ interface FilterFormRecord {
label_user: string[]
review_user1: string[]
review_user2: string[]
first_commit_label_time: DatesRangeValue
first_commit_review1_time: DatesRangeValue
}
function createInitialFilters(): FilterFormRecord {
@@ -71,10 +75,52 @@ function createInitialFilters(): FilterFormRecord {
label_user: [],
review_user1: [],
review_user2: [],
first_commit_label_time: [null, null],
first_commit_review1_time: [null, null],
}
}
const DEFAULT_RECORDS_PER_PAGE_OPTIONS = [10, 15, 20]
const INITIAL_SORT_ACCESSOR = "__initial__"
type SortableTaskSizeAccessor = keyof Task.SizeProps
const SORTABLE_TASK_SIZE_COLUMNS: Array<{
accessor: SortableTaskSizeAccessor
title: string
width: number
}> = [
{ accessor: "object_size", title: "对象总数", width: 110 },
{ accessor: "valid_size", title: "有效对象数", width: 120 },
{ accessor: "new_size", title: "新增对象数", width: 120 },
{ accessor: "key_frame_size", title: "关键帧数", width: 120 },
{ accessor: "dynamic_size", title: "动态对象数", width: 120 },
{ accessor: "static_size", title: "静态对象数", width: 120 },
{ accessor: "prelabel_size", title: "预标注对象数", width: 120 },
{ accessor: "prelabel_modify_size", title: "预标注对象修改数", width: 160 },
{ accessor: "question_size", title: "问题数", width: 120 },
{ accessor: "total_qa_group_size", title: "总问答组数", width: 120 },
{ accessor: "single_qa_group_size", title: "单轮问答组数", width: 120 },
{ accessor: "multiple_qa_group_size", title: "多轮问答组数", width: 120 },
{ accessor: "new_qa_group_size", title: "新增问答组数", width: 120 },
{
accessor: "new_single_qa_group_size",
title: "新增单轮问答组数",
width: 160,
},
{
accessor: "new_multiple_qa_group_size",
title: "新增多轮问答组数",
width: 160,
},
{ accessor: "review_question_size", title: "审核问题数", width: 120 },
{ accessor: "comment_question_size", title: "首次批注问题数", width: 140 },
{ accessor: "review_size", title: "审核对象数", width: 120 },
{ accessor: "review_dynamic_size", title: "审核动态对象数", width: 140 },
{ accessor: "review_static_size", title: "审核静态对象数", width: 140 },
{ accessor: "first_comment_size", title: "批注数(1)", width: 120 },
{ accessor: "second_comment_size", title: "批注数(2)", width: 120 },
]
export interface selectedDataProps {
status: number
@@ -167,6 +213,12 @@ export default function TaskTableContainer(props: {
const [selectedRecords, setSelectedRecords] = useState<Task.DataProps[]>([])
const [loading, setLoading] = useState(false)
const [queryTrigger, setQueryTrigger] = useState(0)
const [sortStatus, setSortStatus] = useState<
DataTableSortStatus<Task.DataProps>
>({
columnAccessor: INITIAL_SORT_ACCESSOR,
direction: "asc",
})
const [workflowTaskId, setWorkflowTaskId] = useState<number | null>(null)
const [dispatchRecordModal, setDispatchModalConfig] = useState<{
@@ -223,9 +275,33 @@ export default function TaskTableContainer(props: {
if (form.review_user2?.length) {
obj.review_user2 = form.review_user2?.map((item) => Number(item))
}
if (form.first_commit_label_time[0] && form.first_commit_label_time[1]) {
obj.first_commit_label_time_start = dayjs(
form.first_commit_label_time[0]
).format("YYYY-MM-DD")
obj.first_commit_label_time_end = dayjs(
form.first_commit_label_time[1]
).format("YYYY-MM-DD")
}
if (
form.first_commit_review1_time[0] &&
form.first_commit_review1_time[1]
) {
obj.first_commit_review1_time_start = dayjs(
form.first_commit_review1_time[0]
).format("YYYY-MM-DD")
obj.first_commit_review1_time_end = dayjs(
form.first_commit_review1_time[1]
).format("YYYY-MM-DD")
}
if (sortStatus.columnAccessor !== INITIAL_SORT_ACCESSOR) {
obj.order_by = `${sortStatus.columnAccessor} ${sortStatus.direction}`
}
return obj
}, [
form.current_uid,
form.first_commit_label_time,
form.first_commit_review1_time,
form.label_user,
form.rejected,
form.review_user1,
@@ -235,6 +311,8 @@ export default function TaskTableContainer(props: {
pageNumber,
pageSize,
projectId,
sortStatus.columnAccessor,
sortStatus.direction,
])
const load = useCallback(async () => {
@@ -528,6 +606,22 @@ export default function TaskTableContainer(props: {
}
const columns = useMemo(() => {
const createSortableSizeColumn = ({
accessor,
title,
width,
}: {
accessor: SortableTaskSizeAccessor
title: string
width: number
}): DataTableColumn<Task.DataProps> => ({
accessor,
title,
width,
sortable: true,
render: (record) => record.label_object_size?.[accessor] ?? "-",
})
const cols: DataTableColumn<Task.DataProps>[] = [
{
accessor: "id",
@@ -595,148 +689,7 @@ export default function TaskTableContainer(props: {
width: 120,
render: (record) => record.review2_username ?? "-",
},
{
accessor: "label_object_size.object_size",
title: "对象总数",
width: 110,
render: (record) => record.label_object_size?.object_size ?? "-",
},
{
accessor: "label_object_size.valid_size",
title: "有效对象数",
width: 120,
render: (record) => record.label_object_size?.valid_size ?? "-",
},
{
accessor: "label_object_size.new_size",
title: "新增对象数",
width: 120,
render: (record) => record.label_object_size?.new_size ?? "-",
},
{
accessor: "label_object_size.key_frame_size",
title: "关键帧数",
width: 120,
render: (record) => record.label_object_size?.key_frame_size ?? "-",
},
{
accessor: "label_object_size.dynamic_size",
title: "动态对象数",
width: 120,
render: (record) => record.label_object_size?.dynamic_size ?? "-",
},
{
accessor: "label_object_size.static_size",
title: "静态对象数",
width: 120,
render: (record) => record.label_object_size?.static_size ?? "-",
},
{
accessor: "label_object_size.prelabel_size",
title: "预标注对象数",
width: 120,
render: (record) => record.label_object_size?.prelabel_size ?? "-",
},
{
accessor: "label_object_size.prelabel_modify_size",
title: "预标注对象修改数",
width: 160,
render: (record) =>
record.label_object_size?.prelabel_modify_size ?? "-",
},
{
accessor: "label_object_size.question_size",
title: "问题数",
width: 120,
render: (record) => record.label_object_size?.question_size ?? "-",
},
{
accessor: "label_object_size.total_qa_group_size",
title: "总问答组数",
width: 120,
render: (record) =>
record.label_object_size?.total_qa_group_size ?? "-",
},
{
accessor: "label_object_size.single_qa_group_size",
title: "单轮问答组数",
width: 120,
render: (record) =>
record.label_object_size?.single_qa_group_size ?? "-",
},
{
accessor: "label_object_size.multiple_qa_group_size",
title: "多轮问答组数",
width: 120,
render: (record) =>
record.label_object_size?.multiple_qa_group_size ?? "-",
},
{
accessor: "label_object_size.new_qa_group_size",
title: "新增问答组数",
width: 120,
render: (record) => record.label_object_size?.new_qa_group_size ?? "-",
},
{
accessor: "label_object_size.new_single_qa_group_size",
title: "新增单轮问答组数",
width: 160,
render: (record) =>
record.label_object_size?.new_single_qa_group_size ?? "-",
},
{
accessor: "label_object_size.new_multiple_qa_group_size",
title: "新增多轮问答组数",
width: 160,
render: (record) =>
record.label_object_size?.new_multiple_qa_group_size ?? "-",
},
{
accessor: "label_object_size.review_question_size",
title: "审核问题数",
width: 120,
render: (record) =>
record.label_object_size?.review_question_size ?? "-",
},
{
accessor: "label_object_size.comment_question_size",
title: "首次批注问题数",
width: 140,
render: (record) =>
record.label_object_size?.comment_question_size ?? "-",
},
{
accessor: "label_object_size.review_size",
title: "审核对象数",
width: 120,
render: (record) => record.label_object_size?.review_size ?? "-",
},
{
accessor: "label_object_size.review_dynamic_size",
title: "审核动态对象数",
width: 140,
render: (record) =>
record.label_object_size?.review_dynamic_size ?? "-",
},
{
accessor: "label_object_size.review_static_size",
title: "审核静态对象数",
width: 140,
render: (record) => record.label_object_size?.review_static_size ?? "-",
},
{
accessor: "label_object_size.first_comment_size",
title: "批注数(1)",
width: 120,
render: (record) => record.label_object_size?.first_comment_size ?? "-",
},
{
accessor: "label_object_size.second_comment_size",
title: "批注数(2)",
width: 120,
render: (record) =>
record.label_object_size?.second_comment_size ?? "-",
},
...SORTABLE_TASK_SIZE_COLUMNS.map(createSortableSizeColumn),
{
accessor: "first_commit_label_time",
title: "标注首次提交时间",
@@ -966,6 +919,36 @@ export default function TaskTableContainer(props: {
setFilters((s) => ({ ...s, review_user2: v }))
}
/>
<DatePickerInput
type="range"
label="标注首次提交时间"
placeholder="请选择时间范围"
valueFormat="YYYY-MM-DD"
locale="zh-cn"
clearable
value={filters.first_commit_label_time}
onChange={(value: DatesRangeValue) =>
setFilters((s) => ({
...s,
first_commit_label_time: value,
}))
}
/>
<DatePickerInput
type="range"
label="审核首次提交时间"
placeholder="请选择时间范围"
valueFormat="YYYY-MM-DD"
locale="zh-cn"
clearable
value={filters.first_commit_review1_time}
onChange={(value: DatesRangeValue) =>
setFilters((s) => ({
...s,
first_commit_review1_time: value,
}))
}
/>
</SimpleGrid>
</Stack>
</Collapse>
@@ -1031,6 +1014,7 @@ export default function TaskTableContainer(props: {
/>
<SettingDataTable<Task.DataProps>
width="100%"
minHeight={0}
style={{ width: "100%", flex: 1 }}
pinFirstColumn
pinLastColumn
@@ -1038,6 +1022,13 @@ export default function TaskTableContainer(props: {
fetching={loading}
records={records}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={(
nextSortStatus: DataTableSortStatus<Task.DataProps>
) => {
setPageNumber(1)
setSortStatus(nextSortStatus)
}}
totalRecords={total}
recordsPerPage={pageSize}
page={pageNumber}

View File

@@ -267,6 +267,7 @@ const LabelPage = ({
setShowMultiFrame,
isView,
setIsView,
renderMode,
scale,
setScale,
objectOperations,
@@ -1307,8 +1308,8 @@ const LabelPage = ({
)
const setDrawType = useCallback(() => {
if (isView) {
setPaperMode("pan")
if (isView || renderMode) {
setPaperMode(null)
} else {
let operationSchema = getOperationSchema(activeOperation)
if (operationSchema) {
@@ -1371,6 +1372,7 @@ const LabelPage = ({
editMode,
getOperationSchema,
isView,
renderMode,
setPaperMode,
setToolOption,
])
@@ -2117,7 +2119,7 @@ const LabelPage = ({
return
}
if (isView) return
if (isView || renderMode) return
if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "h") {
e.preventDefault()
@@ -2265,6 +2267,7 @@ const LabelPage = ({
// 追踪
if (e.key === "i") {
e.preventDefault()
// sam2 暂未有图片接口
paperContainerRef.current.renderTrackingAnnotation()
}
@@ -2426,6 +2429,7 @@ const LabelPage = ({
handleContinueSelectedPolygon,
handleShortcut,
isView,
renderMode,
projectDetail?.label_type,
setEditMode,
setGroupScale,

View File

@@ -108,18 +108,6 @@ export const getLabelImage = (activeImage: string, path: string) => {
})
}
// 辅助标注
export const getAuxiliaryAnnotation = (data: any) => {
return httpFetch<any>({
url: BASE_LABEL_API + "/api/model_server/sam2/sa",
method: "POST",
body: JSON.stringify(data),
headerAttr: {
responseType: "arraybuffer",
},
})
}
const BASE_SAM_API =
process.env.NEXT_PUBLIC_ENV === "production"
? "http://172.16.103.224:8000"
@@ -127,7 +115,7 @@ const BASE_SAM_API =
? "http://172.30.21.211:8000"
: "http://172.30.21.211:8000"
export const getAuxiliaryAnnotation2 = (data: {
export const getAuxiliaryAnnotation = (data: {
project_id: number
image_name: string
prompt: {
@@ -146,18 +134,6 @@ export const getAuxiliaryAnnotation2 = (data: {
})
}
// 模型标注 追踪
export const getTrackingAuxiliaryAnnotation = (data: any) => {
return httpFetch<any>({
url: BASE_LABEL_API + "/api/model_server/sam2/vos",
method: "POST",
body: JSON.stringify(data),
headerAttr: {
responseType: "arraybuffer",
},
})
}
// 用户登录
export const userLogin = (data: { name: string; password: string }) => {
return httpFetch<LoginInfo>({

View File

@@ -1,5 +1,6 @@
export namespace Task {
export interface DataProps {
[key: string]: unknown
id: number
create_date: string
data_type?: number
@@ -74,6 +75,7 @@ export namespace Task {
id?: number[]
label_status?: number[]
label_user?: number[]
// sample: object_size asc/object_size desc
order_by?: string
page_number: number
page_size: number

View File

@@ -1,96 +0,0 @@
# `renderTrackingAnnotation` 流程说明
来源文件:
- `labelimage/components/label/components/PaperContainer.tsx`
- `labelimage/components/label/LabelNossr.tsx`
- `labelimage/components/label/api/label/index.ts`
## 概述
`renderTrackingAnnotation` 是单对象跨帧追踪的入口方法。
它会把当前激活图片上选中的标注对象作为 seed将其轮廓数据发送给追踪模型接口再把模型返回的后续帧轮廓结果写回 `label store`
## Mermaid
```mermaid
flowchart TD
A["在 `LabelNossr.tsx` 中按下 `i`"] --> B["调用 `paperContainerRef.current.renderTrackingAnnotation()`"]
B --> C["执行 `updateLoadingFlag(true)`<br/>并显示加载通知"]
C --> D["从 store 读取运行时状态:<br/>`selectedPath[activeImage]`<br/>`selectedItems`<br/>`rasterSize[activeImage]`<br/>`rasterScale[activeImage]`"]
D --> E{"是否恰好选中了一个标注对象?"}
E -->|未选中| F["提示错误:<br/>`请先选中标注对象后再进行模型调用`"]
E -->|选中了多个| G["提示错误:<br/>`请勿选择多个标注对象进行模型调用`"]
E -->|是| H["在 `label.get(activeImage)` 中查找<br/>满足 `item[0] === id` 的标注对象"]
H --> I{"是否找到 `categoryId` 和 `data`"}
I -->|否| Z["跳过请求参数构造"]
I -->|是| J["提取源标注几何数据:<br/>`data[1]` => 外轮廓 `segmentation`<br/>`data[3]` => 镂空轮廓 `hollow_segmentation`"]
J --> K["将画布坐标转换为原图坐标:<br/>`Math.round(x / currentScale)`<br/>`Math.round(y / currentScale)`"]
K --> L["构造请求参数:<br/>`project_id`<br/>`file_names = [activeImage, ...selectedItems]`<br/>`image_hw = [height, width]`<br/>`contours`<br/>`hollows`"]
L --> M["使用 `msgpack.encode(params)` 序列化"]
M --> N["通过 `getTrackingAuxiliaryAnnotation()`<br/>POST 到 `/api/model_server/sam2/vos`"]
N --> O["以 `arraybuffer` 形式接收响应体"]
O --> P["使用<br/>`msgpack.decode(new Uint8Array(res))`<br/>解码响应数据"]
P --> Q{"resData.msg === 'success'"}
Q -->|否| R["内层流程仅执行 `console.log(error/resData)`"]
Q -->|是| S["读取响应中的 `future_contours`"]
S --> T["克隆当前 `label store`<br/>`safeClone(useLabelStore.getState().label)`"]
T --> U["遍历每个未来帧结果:<br/>目标文件 = `file_names[index + 1]`"]
U --> V["读取目标帧缩放比例:<br/>`rasterScale[fileName]`"]
V --> W["基于 `pathIds` 生成 `newId` 并注册"]
W --> X["将原图坐标还原为画布坐标:<br/>`x * imgScale`<br/>`y * imgScale`"]
X --> Y["将返回轮廓拆分为:<br/>`segmentation`<br/>`hollow_segmentation`"]
Y --> AA["构造标注元组:<br/>`[newId, segmentation, detail, hollow_segmentation]`"]
AA --> AB["将结果合并到<br/>`Map<fileName, Map<categoryId, annotation[]>>`"]
AB --> AC["持久化结果:<br/>`setLabel(nowTaskData)`<br/>`pushStateStack(nowTaskData)`"]
AC --> AD["显示成功通知"]
F --> AE["finally执行 `updateLoadingFlag(false)`"]
G --> AE
Z --> AE
R --> AE
AD --> AE
C --> AF["外层 `try/catch`"]
AF -->|异常| AG["显示通用失败通知:<br/>`模型生成失败`"]
AG --> AE
```
## 数据结构
被追踪的源标注对象,使用和普通多边形标注一致的元组结构:
```ts
;[id, segmentation, detail, hollow_segmentation]
```
各字段含义:
- `item[0]`:标注 id
- `item[1]`:外轮廓多边形
- `item[2]`:详情元数据
- `item[3]`:镂空轮廓多边形
## 为什么使用 `arraybuffer`
这条追踪请求并不是从头到尾都按普通 JSON 方式传输。
- 请求参数先通过 `msgpack.encode(params)` 打包。
- API 封装层把这份打包后的数据发给后端。
- 响应体以 `arraybuffer` 的形式返回。
- `renderTrackingAnnotation` 再通过 `msgpack.decode(new Uint8Array(res))` 还原出 JavaScript 对象。
因此这里必须配置 `responseType: "arraybuffer"`,因为前端预期拿到的是二进制响应体,再自行解码。
## 备注
- 当前帧是 seed 帧,模型返回的结果会写入 `file_names[index + 1]` 对应的后续帧。
- 这个方法更新的是 `label store`,不会直接在当前页面把其他帧的结果立刻画出来。
- 这个方法只支持一次追踪一个被选中的对象。
- 内层请求失败时目前主要是 `console.log`,不一定总会显示专门的失败提示。
- 这个方法里生成的 `detail.image_id` 使用的是 `activeImage`,建议再结合整体数据模型确认一下是否符合预期。

View File

@@ -18,7 +18,7 @@ import {
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import dayjs from "dayjs"
import msgpack from "msgpack-lite"
// import msgpack from "msgpack-lite"
import paper from "paper"
import React, {
DispatchWithoutAction,
@@ -31,13 +31,9 @@ import React, {
useRef,
useState,
} from "react"
import {
getAuxiliaryAnnotation,
getAuxiliaryAnnotation2,
getServerImage,
getTrackingAuxiliaryAnnotation,
} from "../api/label"
import { getAuxiliaryAnnotation, getServerImage } from "../api/label"
import { Project } from "../api/project/typing"
import { labelimagePerformanceConfig } from "../config/performance"
import { LabelState } from "../LabelNossr"
import {
initialDetail,
@@ -51,12 +47,12 @@ import { useBottomToolsStore } from "../useBottomToolsStore"
import { useKeyboardStore } from "../useKeyBoardStore"
import { useOtherToolsStore } from "../useOtherToolsStore"
import { clearSupportAnnotationItem, usePaperStore } from "../usePaperStore"
import { usePaperSupportStore } from "../usePaperSupportStore"
import { renderGroupPath } from "../useRenderGroupPath"
import useRenderTag from "../useRenderTag"
import { useRightToolsStore } from "../useRightToolsStore"
import { useTopToolsStore } from "../useTopToolsStore"
import { checkCommentsIsSame } from "../util"
import { labelimagePerformanceConfig } from "../config/performance"
import { safeClone } from "../utils/clone"
import { labelTypeMap } from "../utils/constants"
import { adjustPoints } from "../utils/paperjs"
@@ -125,6 +121,11 @@ type FrameSwitchGhostState = {
animating: boolean
}
const clonePaperPoint = (point: paper.Point | null | undefined) => {
if (!point) return null
return new paper.Point(point.x, point.y)
}
const normalizePoint = (value: any): NormalizedPoint | null => {
if (value instanceof paper.Point) return [value.x, value.y]
if (value instanceof paper.Segment) {
@@ -179,6 +180,7 @@ const PaperContainer = (
ref: React.Ref<unknown> | undefined
) => {
const { forceUpdate, projectDetail, labelState, updateLoadingFlag } = props
false && updateLoadingFlag
const containerRef = useRef<any>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const [imgSize, setImageSize] = useState<{
@@ -443,6 +445,8 @@ const PaperContainer = (
crosshairStatus,
assistToolEnabled,
assistToolSize,
renderMode,
saveCurrentScale,
showTags,
showTagsConfigs,
} = useTopToolsStore()
@@ -452,6 +456,8 @@ const PaperContainer = (
selectedPath,
updateSelectedPath,
} = useObjectStore()
const currentSelectedPath = selectedPath[activeImage] || []
const currentSelectedPathKey = currentSelectedPath.join(",")
const {
showContextMenu,
@@ -511,6 +517,11 @@ const PaperContainer = (
token: number
imageName: string
} | null>(null)
const renderModePanRef = useRef({
active: false,
lastX: 0,
lastY: 0,
})
const paperScope = useRef<paper.PaperScope>(null)
useEffect(() => {
@@ -869,164 +880,6 @@ const PaperContainer = (
}, [clearPendingFrameSwitch, loadingData])
const renderSupportAnnotation = useCallback(
async (
points: Array<[number, number]>,
tags: number[],
clear_previous_state = false
) => {
try {
notifications.show({
id: "sam2",
message: "模型生成中",
loading: true,
autoClose: false,
})
const operationSchema =
projectDetail?.label_schema_list?.find(
(item) =>
item.category_id === +useTopToolsStore.getState().activeOperation
) || null
const strokeColor = operationSchema
? `rgb(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]})`
: "rgb(0,0,0)"
const fillColor = operationSchema
? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.3)`
: "rgba(0,0,0,0.3)"
const blankColor = operationSchema
? `rgba(${operationSchema.color[0]},${operationSchema.color[1]},${operationSchema.color[2]},0.01)`
: "rgba(0,0,0,0.01)"
const currentScale =
usePaperStore.getState().rasterScale[activeImage] ?? 1
const draw = () => {
points.forEach((point, index) => {
const drawPoint = new paper.Path.Circle({
center: point,
radius: 2,
fillColor: tags[index] === 1 ? "red" : "blue",
strokeColor: tags[index] === 1 ? "red" : "blue",
data: {
id: "support",
type: "point",
},
})
drawPoint.parent = usePaperStore.getState().group!
const raster = usePaperStore
.getState()
.group!.getItems({ data: { name: "pic" } })[0]
if (!raster.contains(drawPoint.position)) {
throw new Error("点位超出图片范围")
}
})
}
console.log(points, tags)
// 绘制点位
clearSupportAnnotationItem()
draw()
const picSize = usePaperStore.getState().rasterSize[activeImage]
const params = {
project_id: projectDetail?.id || 0,
file_name: activeImage,
image_hw:
picSize && picSize.length ? [picSize[1], picSize[0]] : [0, 0],
points_and_box: points.map(([x, y]) => [
Math.round(x / currentScale),
Math.round(y / currentScale),
]),
tags,
clear_previous_state,
}
try {
const res = await getAuxiliaryAnnotation(
msgpack.encode(params) as any
)
const data = msgpack.decode(new Uint8Array(res))
console.log(data)
if (data.msg === "success") {
const { contours, hollows } = data
const outerPaths: any[] = []
const innerPaths: any[] = []
contours.forEach((contour: any, index: number) => {
if (contour.length > 3) {
const path = new paper.Path({
segments: contour.map(([x, y]: [number, number]) => [
x * currentScale,
y * currentScale,
]),
fillColor: blankColor,
strokeColor: strokeColor,
closed: true,
data: {
id: "support",
type: "polygon",
isHollowPolygon: false,
fillColor,
strokeColor,
blankColor,
},
parent: usePaperStore.getState().group,
strokeScaling: false,
})
if (
Math.abs(path.area) < useTopToolsStore.getState().checkSize
) {
path.remove()
} else {
// path.scaling = usePaperStore.getState().group!.scaling;
if (hollows[index]) {
// 更新标志位
path.data.isHollowPolygon = true
innerPaths.push(path)
} else {
// 上色
path.fillColor = new paper.Color(fillColor)
outerPaths.push(path)
}
}
}
})
const compoundPath = new paper.CompoundPath({
children: [...outerPaths, ...innerPaths],
data: {
id: "support",
type: "parent",
},
strokeColor: strokeColor,
strokeWidth: 2,
fillColor: fillColor,
})
compoundPath.parent = usePaperStore.getState().group!
notifications.show({
id: "sam2",
message: "模型生成成功",
color: "green",
})
}
} catch (error) {
console.log(error)
}
} catch (error: any) {
console.log(error)
notifications.show({
id: "sam2",
message: error.message ?? "模型生成失败",
color: "red",
})
if (error.message === "点位超出图片范围") {
usePaperStore
.getState()
.supportTool?.emit("keydown", { key: "escape" })
}
}
},
[activeImage, projectDetail]
)
false && renderSupportAnnotation
const renderSupportAnnotation2 = useCallback(
async (
points: Array<[number, number]>,
tags: number[],
@@ -1130,7 +983,7 @@ const PaperContainer = (
// const picSize = usePaperStore.getState().rasterSize[activeImage]
try {
const data = await getAuxiliaryAnnotation2({
const data = await getAuxiliaryAnnotation({
project_id: projectId,
image_name: activeImage,
// project_id: "image_test_bk",
@@ -1218,180 +1071,174 @@ const PaperContainer = (
[activeImage, projectDetail]
)
const renderTrackingAnnotation = useCallback(async () => {
updateLoadingFlag(true)
try {
notifications.show({
id: "sam2",
message: "模型生成中",
loading: true,
autoClose: false,
})
const ids =
useObjectStore.getState().selectedPath[
useBottomToolsStore.getState().activeImage
]
const picArr = useBottomToolsStore.getState().selectedItems
const [width, height] = usePaperStore.getState().rasterSize[
activeImage
] ?? [0, 0]
const currentScale =
usePaperStore.getState().rasterScale[activeImage] ?? 1
if (ids.length === 1) {
const id = ids[0]
let categoryId: any = null
let data: any = null
const activeImageData = useLabelStore.getState().label.get(activeImage)!
for (let [category, objArr] of activeImageData.entries()) {
objArr.forEach((item) => {
if (item[0] === id) {
categoryId = category
data = item
}
})
}
if (categoryId && data) {
const contours: [number, number][] = []
const tags: boolean[] = []
data[1].forEach((item: any) => {
contours.push(
item.map(([x, y]: any) => [
Math.round(x / currentScale),
Math.round(y / currentScale),
])
)
tags.push(false)
})
data[3].forEach((item: any) => {
contours.push(
item.map(([x, y]: any) => [
Math.round(x / currentScale),
Math.round(y / currentScale),
])
)
tags.push(true)
})
const file_names = [...new Set([activeImage, ...picArr])]
const params = {
project_id: projectDetail?.id || 0,
file_names,
image_hw: [height, width],
contours,
hollows: tags,
}
console.log("传参", params)
try {
const res = await getTrackingAuxiliaryAnnotation(
msgpack.encode(params)
)
const resData = msgpack.decode(new Uint8Array(res))
if (resData.msg === "success") {
const {
future_contours,
}: {
future_contours: {
contours: [number, number][][]
hollows: boolean[]
}[]
} = resData
const nowTaskData = safeClone(useLabelStore.getState().label)
future_contours.forEach(({ contours, hollows }, index) => {
const fileName = file_names[index + 1]
const imgScale =
usePaperStore.getState().rasterScale[fileName] ?? 1
let newId =
useRightToolsStore
.getState()
.pathIds.reduce((a, b) => Math.max(a, b), 0) + 1
useRightToolsStore.getState().pushPathId(newId)
// 处理点位数据
let segmentation: Array<[number, number][]> = []
let hollow_segmentation: Array<[number, number][]> = []
contours.forEach((contour, index) => {
if (!hollows[index])
segmentation.push(
contour.map(([x, y]: any) => [x * imgScale, y * imgScale])
)
else
hollow_segmentation.push(
contour.map(([x, y]: any) => [x * imgScale, y * imgScale])
)
})
// 详情数据
let detail = {
...initialDetail,
uid: usePermissionStore.getState().user_id,
comment: [...useLabelStore.getState().labelDefaultComments],
create_timestamp: dayjs().unix(),
sub_attributes: {},
image_id: activeImage,
category_id: categoryId,
}
const updateData: any = [
newId,
segmentation,
detail,
hollow_segmentation,
]
if (nowTaskData.has(fileName)) {
const categoryMap = nowTaskData.get(fileName)!
if (categoryMap.has(categoryId)) {
const existArr = categoryMap.get(categoryId)
existArr?.push(updateData)
} else {
categoryMap.set(categoryId, [updateData])
}
} else {
let m = new Map()
m.set(categoryId, [updateData])
nowTaskData.set(fileName, m)
}
})
console.log(nowTaskData)
useLabelStore.getState().setLabel(nowTaskData)
useLabelStore.getState().pushStateStack(nowTaskData)
notifications.show({
id: "sam2",
message: "模型生成成功",
color: "green",
})
}
} catch (error) {
console.log(error)
}
}
} else if (!ids.length) {
notifications.show({
id: "sam2",
message: "请先选中标注对象后再进行模型调用",
color: "red",
})
} else {
notifications.show({
id: "sam2",
message: "请勿选择多个标注对象进行模型调用",
color: "red",
})
}
} catch (error) {
console.log(error)
notifications.show({
id: "sam2",
message: "模型生成失败",
color: "red",
})
} finally {
updateLoadingFlag(false)
}
}, [activeImage, projectDetail?.id, updateLoadingFlag])
// const renderTrackingAnnotation = useCallback(async () => {
// updateLoadingFlag(true)
// try {
// notifications.show({
// id: "sam2",
// message: "模型生成中",
// loading: true,
// autoClose: false,
// })
// const ids =
// useObjectStore.getState().selectedPath[
// useBottomToolsStore.getState().activeImage
// ]
// const picArr = useBottomToolsStore.getState().selectedItems
// const [width, height] = usePaperStore.getState().rasterSize[
// activeImage
// ] ?? [0, 0]
// const currentScale =
// usePaperStore.getState().rasterScale[activeImage] ?? 1
// if (ids.length === 1) {
// const id = ids[0]
// let categoryId: any = null
// let data: any = null
// const activeImageData = useLabelStore.getState().label.get(activeImage)!
// for (let [category, objArr] of activeImageData.entries()) {
// objArr.forEach((item) => {
// if (item[0] === id) {
// categoryId = category
// data = item
// }
// })
// }
// if (categoryId && data) {
// const contours: [number, number][] = []
// const tags: boolean[] = []
// data[1].forEach((item: any) => {
// contours.push(
// item.map(([x, y]: any) => [
// Math.round(x / currentScale),
// Math.round(y / currentScale),
// ])
// )
// tags.push(false)
// })
// data[3].forEach((item: any) => {
// contours.push(
// item.map(([x, y]: any) => [
// Math.round(x / currentScale),
// Math.round(y / currentScale),
// ])
// )
// tags.push(true)
// })
// const file_names = [...new Set([activeImage, ...picArr])]
// const params = {
// project_id: projectDetail?.id || 0,
// file_names,
// image_hw: [height, width],
// contours,
// hollows: tags,
// }
// console.log("传参", params)
// try {
// const res = await getTrackingAuxiliaryAnnotation(
// msgpack.encode(params)
// )
// const resData = msgpack.decode(new Uint8Array(res))
// if (resData.msg === "success") {
// const {
// future_contours,
// }: {
// future_contours: {
// contours: [number, number][][]
// hollows: boolean[]
// }[]
// } = resData
// const nowTaskData = safeClone(useLabelStore.getState().label)
// future_contours.forEach(({ contours, hollows }, index) => {
// const fileName = file_names[index + 1]
// const imgScale =
// usePaperStore.getState().rasterScale[fileName] ?? 1
// let newId =
// useRightToolsStore
// .getState()
// .pathIds.reduce((a, b) => Math.max(a, b), 0) + 1
// useRightToolsStore.getState().pushPathId(newId)
// // 处理点位数据
// let segmentation: Array<[number, number][]> = []
// let hollow_segmentation: Array<[number, number][]> = []
// contours.forEach((contour, index) => {
// if (!hollows[index])
// segmentation.push(
// contour.map(([x, y]: any) => [x * imgScale, y * imgScale])
// )
// else
// hollow_segmentation.push(
// contour.map(([x, y]: any) => [x * imgScale, y * imgScale])
// )
// })
// // 详情数据
// let detail = {
// ...initialDetail,
// uid: usePermissionStore.getState().user_id,
// comment: [...useLabelStore.getState().labelDefaultComments],
// create_timestamp: dayjs().unix(),
// sub_attributes: {},
// image_id: activeImage,
// category_id: categoryId,
// }
// const updateData: any = [
// newId,
// segmentation,
// detail,
// hollow_segmentation,
// ]
// if (nowTaskData.has(fileName)) {
// const categoryMap = nowTaskData.get(fileName)!
// if (categoryMap.has(categoryId)) {
// const existArr = categoryMap.get(categoryId)
// existArr?.push(updateData)
// } else {
// categoryMap.set(categoryId, [updateData])
// }
// } else {
// let m = new Map()
// m.set(categoryId, [updateData])
// nowTaskData.set(fileName, m)
// }
// })
// console.log(nowTaskData)
// useLabelStore.getState().setLabel(nowTaskData)
// useLabelStore.getState().pushStateStack(nowTaskData)
// notifications.show({
// id: "sam2",
// message: "模型生成成功",
// color: "green",
// })
// }
// } catch (error) {
// console.log(error)
// }
// }
// } else if (!ids.length) {
// notifications.show({
// id: "sam2",
// message: "请先选中标注对象后再进行模型调用",
// color: "red",
// })
// } else {
// notifications.show({
// id: "sam2",
// message: "请勿选择多个标注对象进行模型调用",
// color: "red",
// })
// }
// } catch (error) {
// console.log(error)
// notifications.show({
// id: "sam2",
// message: "模型生成失败",
// color: "red",
// })
// } finally {
// updateLoadingFlag(false)
// }
// }, [activeImage, projectDetail?.id, updateLoadingFlag])
// const getBase64 = (file: any): Promise<string> =>
// new Promise((resolve, reject) => {
// const reader = new FileReader();
// reader.readAsDataURL(file);
// reader.onload = () => resolve(reader.result as string);
// reader.onerror = (error) => reject(error);
// });
const renderTrackingAnnotation = useCallback(async () => {}, [])
useEffect(() => {
if (setup) {
@@ -1424,7 +1271,7 @@ const PaperContainer = (
let pointTool = new paper.Tool()
initPointTool(pointTool, renderCircle, renderPath, forceUpdate)
let supportTool = new paper.Tool()
initSupportTool(supportTool, renderSupportAnnotation2)
initSupportTool(supportTool, renderSupportAnnotation)
}
return () => {
newGroup.remove()
@@ -1440,7 +1287,7 @@ const PaperContainer = (
initRectangleTool,
initSupportTool,
initViewTool,
renderSupportAnnotation2,
renderSupportAnnotation,
setup,
])
@@ -1812,6 +1659,52 @@ const PaperContainer = (
]
)
const syncRenderModeVisuals = useCallback(
(targetGroup?: paper.Group | null) => {
const group = targetGroup ?? usePaperStore.getState().group
if (!group) return
group.children.forEach((item) => {
const itemData = item.data || {}
const itemType = itemData.type
if (
itemData.name === "pic" ||
itemData.id === "support" ||
!itemData.fillColor ||
!itemData.blankColor ||
[
"mask",
"text",
"tag",
"groupPath",
"pathCircle",
"pathBuff",
"circle",
].includes(itemType)
) {
return
}
const nextFillState = renderMode || itemData.selected ? "fill" : "blank"
if (itemData.renderModeFillState === nextFillState) {
return
}
itemData.renderModeFillState = nextFillState
const nextFillColor =
renderMode || itemData.selected
? itemData.fillColor
: itemData.blankColor
const pathItem = item as paper.PathItem
pathItem.fillColor = nextFillColor
? new paper.Color(nextFillColor)
: null
})
},
[renderMode]
)
// 生成辅助标注结果数据并绘制
const saveSupportAnnotationData = useCallback(() => {
const group = usePaperStore.getState().group
@@ -2163,6 +2056,7 @@ const PaperContainer = (
if (useBottomToolsStore.getState().activeImage !== expectedImage) return
renderGroupPath()
renderTag()
syncRenderModeVisuals()
return
}
@@ -2194,6 +2088,7 @@ const PaperContainer = (
if (useBottomToolsStore.getState().activeImage !== expectedImage) return
renderGroupPath()
renderTag()
syncRenderModeVisuals()
return
}
@@ -2238,6 +2133,7 @@ const PaperContainer = (
if (useBottomToolsStore.getState().activeImage !== expectedImage) return
renderGroupPath()
renderTag()
syncRenderModeVisuals()
}
if (typeof globalThis.requestAnimationFrame === "function") {
@@ -2246,7 +2142,13 @@ const PaperContainer = (
setTimeout(runBatch, 0)
}
},
[clearRenderedAnnotationItems, objectOperations, renderPolygons, renderTag]
[
clearRenderedAnnotationItems,
objectOperations,
renderPolygons,
renderTag,
syncRenderModeVisuals,
]
)
const withPaperGroup = useCallback(
@@ -2365,6 +2267,7 @@ const PaperContainer = (
})
if (!tasks.length) {
syncRenderModeVisuals(targetGroup)
resolve(true)
return
}
@@ -2403,6 +2306,7 @@ const PaperContainer = (
return
}
syncRenderModeVisuals(targetGroup)
resolve(true)
}
@@ -2416,10 +2320,125 @@ const PaperContainer = (
isPendingFrameSwitchStale,
objectOperations,
renderPolygons,
syncRenderModeVisuals,
withPaperGroup,
]
)
useEffect(() => {
if (!setup || loadingData) return
const group = usePaperStore.getState().group
if (!group) return
syncRenderModeVisuals(group)
if (renderMode) {
usePaperSupportStore.getState().clearAll()
useOtherToolsStore.getState().setShowContextMenu({
showContextMenu: false,
position: { top: 0, left: 0 },
imageId: "",
operationId: "",
id: 0,
})
}
}, [
activeImage,
activeImageLabel,
currentSelectedPathKey,
loadingData,
renderMode,
setup,
syncRenderModeVisuals,
])
const stopRenderModePan = useCallback(() => {
renderModePanRef.current.active = false
}, [])
const handleRenderModeOverlayMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
event.stopPropagation()
useOtherToolsStore.getState().setShowContextMenu({
showContextMenu: false,
position: { top: 0, left: 0 },
imageId: "",
operationId: "",
id: 0,
})
if (event.button !== 1) return
renderModePanRef.current = {
active: true,
lastX: event.clientX,
lastY: event.clientY,
}
},
[]
)
useEffect(() => {
if (!renderMode) {
stopRenderModePan()
return
}
const handleMouseMove = (event: MouseEvent) => {
const currentPan = renderModePanRef.current
if (!currentPan.active) return
event.preventDefault()
const group = usePaperStore.getState().group
if (!group) return
const deltaX = event.clientX - currentPan.lastX
const deltaY = event.clientY - currentPan.lastY
if (!deltaX && !deltaY) return
group.position = group.position.add(new paper.Point(deltaX, deltaY))
renderModePanRef.current.lastX = event.clientX
renderModePanRef.current.lastY = event.clientY
}
const handleMouseUp = (event: MouseEvent) => {
if (event.button === 1) {
event.preventDefault()
}
stopRenderModePan()
}
window.addEventListener("mousemove", handleMouseMove, { passive: false })
window.addEventListener("mouseup", handleMouseUp, { passive: false })
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
stopRenderModePan()
}
}, [renderMode, stopRenderModePan])
const getTargetGroupPosition = useCallback(
({
defaultPosition,
preservedPosition,
canPreserve,
}: {
defaultPosition: paper.Point
preservedPosition?: paper.Point | null
canPreserve?: boolean
}) => {
if (saveCurrentScale && canPreserve && preservedPosition) {
return clonePaperPoint(preservedPosition) ?? defaultPosition.clone()
}
return defaultPosition.clone()
},
[saveCurrentScale]
)
const preparePendingFrameSwitch = useCallback(
async (imageName: string, token: number) => {
if (
@@ -2437,6 +2456,12 @@ const PaperContainer = (
clearPendingFrameSwitch(token)
return
}
const preserveGroupPosition =
saveCurrentScale &&
currentVisibleGroup.getItems({ data: { name: "pic" } }).length > 0
const preservedGroupPosition = preserveGroupPosition
? clonePaperPoint(currentVisibleGroup.position)
: null
const stagingGroup = new paper.Group({
applyMatrix: false,
@@ -2521,10 +2546,15 @@ const PaperContainer = (
raster.bounds.width / 2,
raster.bounds.height / 2
)
stagingGroup.position = new paper.Point(
const centeredGroupPosition = new paper.Point(
raster.bounds.width / 2,
raster.bounds.height / 2
)
stagingGroup.position = getTargetGroupPosition({
defaultPosition: centeredGroupPosition,
preservedPosition: preservedGroupPosition,
canPreserve: preserveGroupPosition,
})
raster.sendToBack()
const imageData = useLabelStore.getState().label.get(imageName)
@@ -2569,6 +2599,7 @@ const PaperContainer = (
[
clearPendingFrameSwitch,
commitFrameSwitch,
getTargetGroupPosition,
imgSize?.clientHeight,
imgSize?.clientWidth,
isPendingFrameSwitchStale,
@@ -2576,6 +2607,7 @@ const PaperContainer = (
reconcileRasterScaleForImage,
renderImagePolygonsBatchedToGroup,
renderTag,
saveCurrentScale,
setGroup,
setRasterPath,
setRasterSize,
@@ -2660,6 +2692,11 @@ const PaperContainer = (
if (activeImage && projectDetail?.id) {
const requestId = ++rasterLoadRequestIdRef.current
const requestImage = activeImage
const preserveGroupPosition =
saveCurrentScale && getPicRasters(paperGroup).length > 0
const preservedGroupPosition = preserveGroupPosition
? clonePaperPoint(paperGroup.position)
: null
rasterLoadingImageRef.current = requestImage
cancelRasterFadeTransition()
getPicRasters(paperGroup).forEach((item) => {
@@ -2818,6 +2855,10 @@ const PaperContainer = (
raster.bounds.width / 2,
raster.bounds.height / 2
)
const centeredGroupPosition = new paper.Point(
raster.bounds.width / 2,
raster.bounds.height / 2
)
raster.visible = true
const previousRasters = getPicRasters(paperGroup).filter(
(item) => item !== raster
@@ -2871,10 +2912,11 @@ const PaperContainer = (
})
}
usePaperStore.getState().group!.position = new paper.Point(
raster.bounds.width / 2,
raster.bounds.height / 2
)
paperGroup.position = getTargetGroupPosition({
defaultPosition: centeredGroupPosition,
preservedPosition: preservedGroupPosition,
canPreserve: preserveGroupPosition,
})
setRasterPath(new paper.Path.Rectangle(raster.bounds))
raster.sendToBack()
renderedImageRef.current = requestImage
@@ -2896,9 +2938,11 @@ const PaperContainer = (
cancelRasterFadeTransition,
decodeFrameImage,
getPicRasters,
getTargetGroupPosition,
imgSize?.clientHeight,
imgSize?.clientWidth,
projectDetail?.id,
saveCurrentScale,
setRasterPath,
setRasterScale,
setRasterSize,
@@ -3008,6 +3052,9 @@ const PaperContainer = (
: undefined) ||
(rasterItems.length ? rasterItems[rasterItems.length - 1] : undefined)
if (!currentRaster) return false
const preservedGroupPosition = saveCurrentScale
? clonePaperPoint(group.position)
: null
const rasterSizeStore = usePaperStore.getState().rasterSize
const rawRasterSize = rasterSizeStore[activeImage] ?? [
@@ -3061,10 +3108,15 @@ const PaperContainer = (
currentRaster.bounds.width / 2,
currentRaster.bounds.height / 2
)
group.position = new paper.Point(
const centeredGroupPosition = new paper.Point(
currentRaster.bounds.width / 2,
currentRaster.bounds.height / 2
)
group.position = getTargetGroupPosition({
defaultPosition: centeredGroupPosition,
preservedPosition: preservedGroupPosition,
canPreserve: saveCurrentScale,
})
setRasterPath(new paper.Path.Rectangle(currentRaster.bounds))
setRasterScale(activeImage, nextScale)
@@ -3078,9 +3130,11 @@ const PaperContainer = (
}, [
activeImage,
getPicRasters,
getTargetGroupPosition,
imgSize?.clientHeight,
imgSize?.clientWidth,
renderTag,
saveCurrentScale,
scaleRenderedAnnotationItemsInPlace,
setRasterPath,
setRasterScale,
@@ -3926,7 +3980,7 @@ const PaperContainer = (
renderPolygons,
handleCrosshairMove,
renderTrackingAnnotation,
renderSupportAnnotation2,
renderSupportAnnotation,
saveSupportAnnotationData,
copyAnnotationDataToMultiFrame,
}))
@@ -4024,6 +4078,22 @@ const PaperContainer = (
}}
/>
)}
{renderMode && (
<Box
aria-hidden
onContextMenu={(event) => {
event.preventDefault()
}}
onMouseDown={handleRenderModeOverlayMouseDown}
style={{
position: "absolute",
inset: 0,
zIndex: 47,
background: "rgba(128, 128, 128, 0.24)",
pointerEvents: "auto",
}}
/>
)}
{contextMenu}
<Modal
opened={confirmModalOpened}

View File

@@ -355,13 +355,10 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
if (operationMap) {
const objList = operationMap.get(operationId)
if (objList && objList.length) {
let hideFlag = true
objList.forEach(([id]) => {
const objStatus =
useObjectStore.getState().pathStatus[`${imgId}${id}`]
if (objStatus && !objStatus["HIDE"]) {
hideFlag = false
}
const hideFlag = objList.every(([id]) => {
return useObjectStore.getState().pathStatus[`${imgId}${id}`]?.["HIDE"]
? true
: false
})
updateOperationStatus(imgId + operationId, "HIDE", hideFlag)
}
@@ -401,6 +398,39 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
[activeImage]
)
const activeImageOperations = labelState.get(activeImage) || []
const isObjectHidden = (imageId: string, objectId: number) => {
return pathStatus[imageId + objectId]?.["HIDE"] ?? false
}
const isOperationHidden = (imageId: string, operationId: string) => {
const operationChildren = storeLabel.get(imageId)?.get(operationId) || []
return (
operationChildren.length > 0 &&
operationChildren.every(([objectId]) => isObjectHidden(imageId, objectId))
)
}
const totalObjectCount = activeImageOperations.reduce(
(count, operationId) => {
return (
count + (storeLabel.get(activeImage)?.get(operationId)?.length || 0)
)
},
0
)
const areAllObjectsHidden =
totalObjectCount > 0 &&
activeImageOperations.every((operationId) => {
const operationChildren =
storeLabel.get(activeImage)?.get(operationId) || []
return operationChildren.every(([objectId]) =>
isObjectHidden(activeImage, objectId)
)
})
// const renderObjectName = useCallback((name: string, input: string) => {
// const index = name?.indexOf(input);
// const beforeStr = name?.substring(0, index);
@@ -509,9 +539,22 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
<Box h="100%" w="100%">
<Flex direction="column" h="100%" gap="xs">
<Flex justify="space-between" align="center" px="md" py="xs">
<Title order={4} size="h4">
</Title>
<Group gap="xs">
<ActionIcon
variant="subtle"
aria-label={areAllObjectsHidden ? "显示全部对象" : "隐藏全部对象"}
onClick={() => {
if (!totalObjectCount) return
activeImageOperations.forEach((operationId) => {
operationHide(operationId, !areAllObjectsHidden)
})
}}>
{areAllObjectsHidden ? <EyeOff size={16} /> : <Eye size={16} />}
</ActionIcon>
<Title order={4} size="h4">
</Title>
</Group>
<Group gap="xs">
<TextInput
w={96}
@@ -566,441 +609,424 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
}}>
<ScrollArea style={{ flex: 1 }} px="md">
<Stack gap="xs" pb="md">
{labelState.get(activeImage) &&
labelState.get(activeImage)?.map((operationId) => {
const operationSchema = getOperationSchema(operationId)
const isCollapsed =
operationStatus[activeImage + operationId]?.["COLLAPSE"]
const isHide =
operationStatus[activeImage + operationId]?.HIDE
const hasSubAttrError = getOperationSubAttrStatus(operationId)
const operationLabel = operationSchema?.label_class
const operationChildren =
storeLabel.get(activeImage)?.get(operationId) || []
const objectCount = operationChildren.length
const visibleChildren = operationChildren.filter((child) => {
const { comment } = child[2]
let visibleFlag = true
if (comment && comment.length) {
const latest_comment =
(taskDetail && taskDetail.label_status === 2) ||
comment.length === 1
? comment[comment.length - 1]
: comment[comment.length - 2]
let type = latest_comment.comment_type
if (type === 2) {
const { last_modified_timestamp } = child[2]
if (
last_modified_timestamp &&
last_modified_timestamp > latest_comment.date
)
type = 3
}
visibleFlag = checkBoxsChecked[3 - type]
{activeImageOperations.map((operationId) => {
const operationSchema = getOperationSchema(operationId)
const isCollapsed =
operationStatus[activeImage + operationId]?.["COLLAPSE"]
const isHide = isOperationHidden(activeImage, operationId)
const hasSubAttrError = getOperationSubAttrStatus(operationId)
const operationLabel = operationSchema?.label_class
const operationChildren =
storeLabel.get(activeImage)?.get(operationId) || []
const objectCount = operationChildren.length
const visibleChildren = operationChildren.filter((child) => {
const { comment } = child[2]
let visibleFlag = true
if (comment && comment.length) {
const latest_comment =
(taskDetail && taskDetail.label_status === 2) ||
comment.length === 1
? comment[comment.length - 1]
: comment[comment.length - 2]
let type = latest_comment.comment_type
if (type === 2) {
const { last_modified_timestamp } = child[2]
if (
last_modified_timestamp &&
last_modified_timestamp > latest_comment.date
)
type = 3
}
if (checkBoxsChecked.every((value) => !value))
visibleFlag = true
return visibleFlag
})
return (
<Box key={operationId} w="100%">
<Paper withBorder p="xs" radius="md" shadow="sm">
<Flex align="center" gap="xs" style={{ minWidth: 0 }}>
<Flex
align="center"
gap="xs"
style={{ flex: 1, minWidth: 0 }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={() =>
operationHide(operationId, !isHide)
}>
{!isHide ? (
<Eye size={16} />
) : (
<EyeOff size={16} />
)}
</ActionIcon>
<Group
gap={8}
wrap="nowrap"
style={{ flex: 1, minWidth: 0 }}>
<Group
gap={6}
wrap="nowrap"
px="xs"
py={4}
style={{
flexShrink: 0,
borderRadius: 999,
border: `1px solid ${
hasSubAttrError
? "var(--mantine-color-red-2)"
: "var(--mantine-color-gray-2)"
}`,
background: hasSubAttrError
? "var(--mantine-color-red-0)"
: "var(--mantine-color-gray-0)",
}}>
{renderOperationIcon(operationSchema)}
<Text
size="xs"
fw={700}
c={hasSubAttrError ? "red" : "dimmed"}
style={{
whiteSpace: "nowrap",
lineHeight: 1,
}}>
{renderShortcutKey(operationId)}
</Text>
</Group>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text
size="sm"
fw={500}
c={hasSubAttrError ? "red" : undefined}
title={operationLabel || undefined}
style={{
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{operationLabel}
</Text>
</Box>
</Group>
</Flex>
visibleFlag = checkBoxsChecked[3 - type]
}
if (checkBoxsChecked.every((value) => !value))
visibleFlag = true
return visibleFlag
})
return (
<Box key={operationId} w="100%">
<Paper withBorder p="xs" radius="md" shadow="sm">
<Flex align="center" gap="xs" style={{ minWidth: 0 }}>
<Flex
align="center"
gap="xs"
style={{ flex: 1, minWidth: 0 }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={() => operationHide(operationId, !isHide)}>
{!isHide ? <Eye size={16} /> : <EyeOff size={16} />}
</ActionIcon>
<Group
gap={6}
gap={8}
wrap="nowrap"
style={{ flexShrink: 0, marginLeft: "auto" }}>
<TextInput
w={88}
size="xs"
placeholder="搜索子属性"
value={searchSubAttrs[operationId] || ""}
onChange={(e) => {
setSearchSubAttrs((searchValues) => ({
...searchValues,
[operationId]: e.target.value,
}))
}}
onKeyDown={(e) =>
handleSelectedBySearchSubAttr(e, operationId)
}
/>
<Badge
size="sm"
variant="light"
radius="xl"
color={hasSubAttrError ? "red" : "gray"}>
{objectCount}
</Badge>
<ActionIcon
variant="subtle"
size="sm"
onClick={() => {
updateOperationStatus(
activeImage + operationId,
"COLLAPSE",
!isCollapsed
)
style={{ flex: 1, minWidth: 0 }}>
<Group
gap={6}
wrap="nowrap"
px="xs"
py={4}
style={{
flexShrink: 0,
borderRadius: 999,
border: `1px solid ${
hasSubAttrError
? "var(--mantine-color-red-2)"
: "var(--mantine-color-gray-2)"
}`,
background: hasSubAttrError
? "var(--mantine-color-red-0)"
: "var(--mantine-color-gray-0)",
}}>
{!isCollapsed ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</ActionIcon>
{renderOperationIcon(operationSchema)}
<Text
size="xs"
fw={700}
c={hasSubAttrError ? "red" : "dimmed"}
style={{
whiteSpace: "nowrap",
lineHeight: 1,
}}>
{renderShortcutKey(operationId)}
</Text>
</Group>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text
size="sm"
fw={500}
c={hasSubAttrError ? "red" : undefined}
title={operationLabel || undefined}
style={{
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{operationLabel}
</Text>
</Box>
</Group>
</Flex>
</Paper>
<Collapse in={!isCollapsed}>
<Stack gap="xs" mt="xs" ml="xs">
{visibleChildren.map((child, childIndex) => {
const { comment } = child[2]
const isSelected = selectedPath[
activeImage
]?.includes(child[0])
const isChildHide =
pathStatus[activeImage + child[0]]?.["HIDE"]
const hasChildError = getSubAttrStatus(
getOperationSchema(operationId),
child[2]?.sub_attributes
)
const isLastVisibleChild =
childIndex === visibleChildren.length - 1
let textColor = undefined
let connectorColor = "var(--mantine-color-gray-4)"
if (hasChildError) {
textColor = "red"
connectorColor = "var(--mantine-color-red-3)"
} else if (isSelected) {
textColor = "white"
connectorColor =
"var(--mantine-primary-color-filled)"
<Group
gap={6}
wrap="nowrap"
style={{ flexShrink: 0, marginLeft: "auto" }}>
<TextInput
w={88}
size="xs"
placeholder="搜索子属性"
value={searchSubAttrs[operationId] || ""}
onChange={(e) => {
setSearchSubAttrs((searchValues) => ({
...searchValues,
[operationId]: e.target.value,
}))
}}
onKeyDown={(e) =>
handleSelectedBySearchSubAttr(e, operationId)
}
/>
<Badge
size="sm"
variant="light"
radius="xl"
color={hasSubAttrError ? "red" : "gray"}>
{objectCount}
</Badge>
<ActionIcon
variant="subtle"
size="sm"
onClick={() => {
updateOperationStatus(
activeImage + operationId,
"COLLAPSE",
!isCollapsed
)
}}>
{!isCollapsed ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</ActionIcon>
</Group>
</Flex>
</Paper>
<Collapse in={!isCollapsed}>
<Stack gap="xs" mt="xs" ml="xs">
{visibleChildren.map((child, childIndex) => {
const { comment } = child[2]
const isSelected = selectedPath[
activeImage
]?.includes(child[0])
const isChildHide =
pathStatus[activeImage + child[0]]?.["HIDE"]
const hasChildError = getSubAttrStatus(
getOperationSchema(operationId),
child[2]?.sub_attributes
)
const isLastVisibleChild =
childIndex === visibleChildren.length - 1
return (
let textColor = undefined
let connectorColor = "var(--mantine-color-gray-4)"
if (hasChildError) {
textColor = "red"
connectorColor = "var(--mantine-color-red-3)"
} else if (isSelected) {
textColor = "white"
connectorColor =
"var(--mantine-primary-color-filled)"
}
return (
<Box
key={child[0]}
pl="lg"
style={{ position: "relative" }}>
<Box
key={child[0]}
pl="lg"
style={{ position: "relative" }}>
style={{
position: "absolute",
left: 7,
top: 0,
bottom: isLastVisibleChild ? "75%" : 0,
width: 1.5,
borderRadius: 999,
background: connectorColor,
opacity: isSelected ? 0.9 : 0.35,
}}
/>
{isLastVisibleChild ? (
<Box
style={{
position: "absolute",
left: 7,
top: 0,
bottom: isLastVisibleChild ? "75%" : 0,
width: 1.5,
borderRadius: 999,
background: connectorColor,
opacity: isSelected ? 0.9 : 0.35,
top: "calc(50% - 10px)",
width: 14,
height: 10,
borderLeft: `1.5px solid ${connectorColor}`,
borderBottom: `1.5px solid ${connectorColor}`,
borderBottomLeftRadius: 10,
opacity: isSelected ? 0.9 : 0.55,
}}
/>
{isLastVisibleChild ? (
) : (
<>
<Box
style={{
position: "absolute",
left: 7,
top: "calc(50% - 10px)",
top: "50%",
width: 14,
height: 10,
borderLeft: `1.5px solid ${connectorColor}`,
borderBottom: `1.5px solid ${connectorColor}`,
borderBottomLeftRadius: 10,
opacity: isSelected ? 0.9 : 0.55,
borderTop: `1.5px solid ${connectorColor}`,
opacity: isSelected ? 0.9 : 0.45,
}}
/>
) : (
<>
<Box
style={{
position: "absolute",
left: 7,
top: "50%",
width: 14,
borderTop: `1.5px solid ${connectorColor}`,
opacity: isSelected ? 0.9 : 0.45,
}}
/>
<Box
style={{
position: "absolute",
left: 4,
top: "calc(50% - 3px)",
width: 6,
height: 6,
borderRadius: 999,
background: connectorColor,
boxShadow:
"0 0 0 2px var(--mantine-color-body)",
opacity: isSelected ? 1 : 0.85,
}}
/>
</>
)}
<Paper
withBorder
p="xs"
radius="md"
shadow="sm"
bg={
isSelected
? "var(--mantine-primary-color-filled)"
: "var(--mantine-color-gray-0)"
}
c={isSelected ? "white" : undefined}
onClick={() =>
handleSelect(child[0], operationId)
}
style={{ cursor: "pointer" }}>
<Flex justify="space-between" align="center">
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
c={isSelected ? "white" : undefined}
onClick={(e) => {
e.stopPropagation()
objectHide(
operationId,
child[0],
!isChildHide
)
}}>
{!isChildHide ? (
<Eye size={16} />
) : (
<EyeOff size={16} />
)}
</ActionIcon>
<Text size="sm" c={textColor}>
{child[0]?.toString()}
</Text>
<Tooltip
label={
<Stack gap={0}>
<Box
style={{
position: "absolute",
left: 4,
top: "calc(50% - 3px)",
width: 6,
height: 6,
borderRadius: 999,
background: connectorColor,
boxShadow:
"0 0 0 2px var(--mantine-color-body)",
opacity: isSelected ? 1 : 0.85,
}}
/>
</>
)}
<Paper
withBorder
p="xs"
radius="md"
shadow="sm"
bg={
isSelected
? "var(--mantine-primary-color-filled)"
: "var(--mantine-color-gray-0)"
}
c={isSelected ? "white" : undefined}
onClick={() =>
handleSelect(child[0], operationId)
}
style={{ cursor: "pointer" }}>
<Flex justify="space-between" align="center">
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
c={isSelected ? "white" : undefined}
onClick={(e) => {
e.stopPropagation()
objectHide(
operationId,
child[0],
!isChildHide
)
}}>
{!isChildHide ? (
<Eye size={16} />
) : (
<EyeOff size={16} />
)}
</ActionIcon>
<Text size="sm" c={textColor}>
{child[0]?.toString()}
</Text>
<Tooltip
label={
<Stack gap={0}>
<Group gap={4}>
<Text size="xs"></Text>
<Text size="xs">
{
userOpts.find(
(item) =>
item.value === child[2]?.uid
)?.label
}
</Text>
<Text size="xs">
{dayjs(
(child[2]?.create_timestamp ||
0) * 1000
).format("YYYY-MM-DD HH:mm:ss")}
</Text>
</Group>
{child[2]?.first_modified_uid && (
<Group gap={4}>
<Text size="xs"></Text>
<Text size="xs"></Text>
<Text size="xs">
{
userOpts.find(
(item) =>
item.value ===
child[2]?.uid
child[2]
?.first_modified_uid
)?.label
}
</Text>
<Text size="xs">
{dayjs(
(child[2]?.create_timestamp ||
(child[2]
?.first_modified_timestamp ||
0) * 1000
).format("YYYY-MM-DD HH:mm:ss")}
</Text>
</Group>
{child[2]?.first_modified_uid && (
<Group gap={4}>
<Text size="xs">
</Text>
<Text size="xs">
{
userOpts.find(
(item) =>
item.value ===
child[2]
?.first_modified_uid
)?.label
}
</Text>
<Text size="xs">
{dayjs(
(child[2]
?.first_modified_timestamp ||
0) * 1000
).format(
"YYYY-MM-DD HH:mm:ss"
)}
</Text>
</Group>
)}
{child[2]?.last_modified_uid && (
<Group gap={4}>
<Text size="xs">
</Text>
<Text size="xs">
{
userOpts.find(
(item) =>
item.value ===
child[2]
?.last_modified_uid
)?.label
}
</Text>
<Text size="xs">
{dayjs(
(child[2]
?.last_modified_timestamp ||
0) * 1000
).format(
"YYYY-MM-DD HH:mm:ss"
)}
</Text>
</Group>
)}
</Stack>
}
multiline
bg="white"
c="black"
withArrow>
<Info
size={16}
style={{ cursor: "pointer" }}
color={isSelected ? "white" : "black"}
/>
</Tooltip>
)}
{child[2]?.last_modified_uid && (
<Group gap={4}>
<Text size="xs"></Text>
<Text size="xs">
{
userOpts.find(
(item) =>
item.value ===
child[2]
?.last_modified_uid
)?.label
}
</Text>
<Text size="xs">
{dayjs(
(child[2]
?.last_modified_timestamp ||
0) * 1000
).format("YYYY-MM-DD HH:mm:ss")}
</Text>
</Group>
)}
</Stack>
}
multiline
bg="white"
c="black"
withArrow>
<Info
size={16}
style={{ cursor: "pointer" }}
color={isSelected ? "white" : "black"}
/>
</Tooltip>
</Group>
{comment ? (
<Group gap={4}>
{comment.map((c: any) => {
const count =
c.check_box_comments.length +
c.text_comments.length
const { last_modified_timestamp } =
child[2]
let flag = false
if (
count > 0 &&
last_modified_timestamp &&
last_modified_timestamp > c.date
)
flag = true
let badgeColor = "gray"
if (c.comment_type === 1)
badgeColor = "green"
if (c.comment_type === 2)
badgeColor = "red"
if (flag) badgeColor = "yellow"
return (
<Popover
key={`${child[0]}${c.turn}${c.times}`}
position="left"
withArrow>
<Popover.Target>
<Badge
size="xs"
circle
color={badgeColor}
style={{
cursor: "pointer",
}}>
{count}
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Text size="xs" c={badgeColor}>
{`P${child[2].image_id}${
child[2].uid
}${c.check_box_comments.join(
","
)},${c.text_comments.join(
","
)} ${
c.date
? dayjs(
c.date * 1000
).format(
"YYYY-MM-DD HH:mm:ss"
)
: ""
}`}
</Text>
</Popover.Dropdown>
</Popover>
)
})}
</Group>
{comment ? (
<Group gap={4}>
{comment.map((c: any) => {
const count =
c.check_box_comments.length +
c.text_comments.length
const { last_modified_timestamp } =
child[2]
let flag = false
if (
count > 0 &&
last_modified_timestamp &&
last_modified_timestamp > c.date
)
flag = true
let badgeColor = "gray"
if (c.comment_type === 1)
badgeColor = "green"
if (c.comment_type === 2)
badgeColor = "red"
if (flag) badgeColor = "yellow"
return (
<Popover
key={`${child[0]}${c.turn}${c.times}`}
position="left"
withArrow>
<Popover.Target>
<Badge
size="xs"
circle
color={badgeColor}
style={{
cursor: "pointer",
}}>
{count}
</Badge>
</Popover.Target>
<Popover.Dropdown>
<Text size="xs" c={badgeColor}>
{`P${child[2].image_id}${
child[2].uid
}${c.check_box_comments.join(
","
)},${c.text_comments.join(
","
)} ${
c.date
? dayjs(
c.date * 1000
).format(
"YYYY-MM-DD HH:mm:ss"
)
: ""
}`}
</Text>
</Popover.Dropdown>
</Popover>
)
})}
</Group>
) : null}
</Flex>
</Paper>
</Box>
)
})}
</Stack>
</Collapse>
</Box>
)
})}
) : null}
</Flex>
</Paper>
</Box>
)
})}
</Stack>
</Collapse>
</Box>
)
})}
</Stack>
</ScrollArea>
</Collapse>

View File

@@ -180,6 +180,8 @@ const TopTools = (
const {
isView,
setIsView,
renderMode,
setRenderMode,
saveCurrentScale,
setSaveCurrentScale,
showMultiFrame,
@@ -1896,10 +1898,16 @@ const TopTools = (
<SquarePen style={{ width: 20, height: 20 }} />
</ActionIcon>
)}
<Eclipse
style={{ width: 20, height: 20, display: "none" }}
color="gray"
/>
<Tooltip label={renderMode ? "关闭渲染" : "开启渲染"}>
<ActionIcon
variant="transparent"
c={renderMode ? "var(--mantine-color-text)" : "gray"}
onClick={() => {
setRenderMode(!renderMode)
}}>
<Eclipse style={{ width: 20, height: 20 }} />
</ActionIcon>
</Tooltip>
<CopySlash style={{ width: 20, height: 20, display: "none" }} />
</Flex>
<Flex

View File

@@ -1,17 +1,17 @@
import { Comment } from "./api/label/typing"
import { Project } from "./api/project/typing"
import { notifications } from "@mantine/notifications"
import dayjs from "dayjs"
import paper from "paper"
import { DispatchWithoutAction } from "react"
import { create } from "zustand"
import { usePermissionStore } from "./store/auth"
import { Comment } from "./api/label/typing"
import { Project } from "./api/project/typing"
import {
initialDetail,
useKeyEventStore,
useLabelStore,
useObjectStore,
} from "./store"
import { usePermissionStore } from "./store/auth"
import { useBottomToolsStore } from "./useBottomToolsStore"
import { useKeyboardStore } from "./useKeyBoardStore"
import { useOtherToolsStore } from "./useOtherToolsStore"
@@ -318,7 +318,250 @@ const hidePaperContextMenu = () => {
})
}
const PAPER_OBJECT_TYPES = ["rectangle", "polygon", "brush", "point"] as const
const PAPER_CONTEXT_HIT_TYPES = [
...PAPER_OBJECT_TYPES,
"circle",
"mask",
"pathBuff",
"pathCircle",
] as const
const isPaperObjectType = (type: unknown) =>
typeof type === "string" &&
PAPER_OBJECT_TYPES.includes(type as (typeof PAPER_OBJECT_TYPES)[number])
const isPaperContextHitType = (type: unknown) =>
typeof type === "string" &&
PAPER_CONTEXT_HIT_TYPES.includes(
type as (typeof PAPER_CONTEXT_HIT_TYPES)[number]
)
const getPaperCursorByMode = (
mode: PaperState["mode"] = usePaperStore.getState().mode
) => {
if (
["polygon", "rectangle", "brush", "point", "support"].includes(mode || "")
) {
return "crosshair"
}
return "default"
}
const syncPaperCursorByMode = (
mode: PaperState["mode"] = usePaperStore.getState().mode
) => {
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = getPaperCursorByMode(mode)
}
}
const clearPaperSelectedObjects = () => {
const group = usePaperStore.getState().group
if (!group) return
group
.getItems({
match: (item: paper.Item) => !!item.data?.selected,
})
.forEach((item) => {
item.data.selected = false
})
usePaperSupportStore.getState().clearAll()
const activeImage = useBottomToolsStore.getState().activeImage
const selectedIds = [
...(useObjectStore.getState().selectedPath[activeImage] || []),
]
selectedIds.forEach((id) => {
usePaperStore
.getState()
.getItemsById(id)
.forEach((item) => {
if (
(item instanceof paper.Path || item instanceof paper.CompoundPath) &&
["rectangle", "polygon"].includes(item.data?.type || "")
) {
item.fillColor = item.data?.blankColor || item.fillColor || null
}
})
})
selectedIds.forEach((id) => {
useObjectStore.getState().updateSelectedPath(activeImage, "DELETE", id)
})
}
const applyPaperSelectedPath = (item: paper.Path | paper.CompoundPath) => {
if (item.data.type === "rectangle" && item instanceof paper.Path) {
item.data.selected = true
usePaperSupportStore.getState().handleBufferPaths(item)
usePaperSupportStore.getState().handleCircles(item)
item.fillColor = item.data.fillColor || item.fillColor || null
} else if (item.data.type === "brush") {
item.data.selected = true
if (item instanceof paper.Path) {
usePaperSupportStore.getState().handleMask(item)
usePaperSupportStore.getState().handleBufferPaths(item)
usePaperSupportStore.getState().handleCircles(item)
}
} else if (item.data.type === "point" && item instanceof paper.Path) {
item.data.selected = true
usePaperSupportStore.getState().handleMask(item)
usePaperSupportStore.getState().handleText(item)
usePaperSupportStore.getState().handleBufferPaths(item)
usePaperSupportStore.getState().handleCircles(item)
} else if (item.data.type === "polygon") {
item.data.selected = true
if (!(item instanceof paper.Path) || !item.data?.isHollowPolygon) {
item.fillColor = item.data.fillColor || item.fillColor || null
}
}
item.bringToFront()
}
const getPaperSelectionPath = (
id: number,
localPoint: paper.Point,
preferredItem?: paper.Item | null
) => {
if (
preferredItem instanceof paper.Path &&
isPaperObjectType(preferredItem.data?.type)
) {
return preferredItem
}
const objectPaths = usePaperStore
.getState()
.getItemsById(id)
.filter(
(item): item is paper.Path =>
item instanceof paper.Path && isPaperObjectType(item.data?.type)
)
if (!objectPaths.length) return null
if (objectPaths.length === 1) return objectPaths[0]
let nearestPath = objectPaths[0]
for (let index = 1; index < objectPaths.length; index += 1) {
const item = objectPaths[index]
if (
item.getNearestPoint(localPoint).getDistance(localPoint) <
nearestPath.getNearestPoint(localPoint).getDistance(localPoint)
) {
nearestPath = item
}
}
return nearestPath
}
const trySelectPaperObjectByPoint = (clickPoint: paper.Point) => {
const group = usePaperStore.getState().group
if (!group) return false
const hitResult = group.hitTest(clickPoint, {
segments: true,
stroke: true,
curves: true,
fill: true,
guides: false,
tolerance: usePaperStore.getState().paperScope
? 8 / useTopToolsStore.getState().scale
: 0,
match: (hit: paper.HitResult) =>
!!hit.item.data?.id && isPaperContextHitType(hit.item.data?.type),
})
const id = hitResult?.item?.data?.id
if (!id) return false
clearPaperSelectedObjects()
const localPoint = group.globalToLocal(clickPoint)
const selectedPath = getPaperSelectionPath(id, localPoint, hitResult?.item)
const operationId = (
selectedPath?.data?.operationId ||
usePaperStore.getState().getItemById(id)?.data?.operationId ||
hitResult?.item?.data?.operationId ||
""
).toString()
usePaperStore
.getState()
.getItemsById(id)
.forEach((item) => {
if (!(item instanceof paper.Path || item instanceof paper.CompoundPath))
return
if (!isPaperObjectType(item.data?.type)) return
if (item.data.type === "polygon") {
applyPaperSelectedPath(item)
return
}
if (
item instanceof paper.CompoundPath ||
(selectedPath && item === selectedPath)
) {
applyPaperSelectedPath(item)
}
})
const activeImage = useBottomToolsStore.getState().activeImage
useObjectStore.getState().updateSelectedPath(activeImage, "ADD", id)
const category = useTopToolsStore
.getState()
.objectOperations.find(
(item) => item.category_id.toString() === operationId
)
const { top, left } = getShowContextMenuPosition(
clickPoint,
(category || {}) as Project.LabelSchemaList,
5,
5
)
useOtherToolsStore.getState().setShowContextMenu({
showContextMenu: true,
position: { top, left },
imageId:
selectedPath?.data?.imageId ||
hitResult?.item?.data?.imageId ||
activeImage,
operationId,
id,
})
return true
}
let isMiddleMousePanning = false
let hoveredBufferPathInfo: {
id: number
index: number
path: paper.Path
clearHighlight: () => void
scheduleClear: () => void
cancelScheduledClear: () => void
} | null = null
let hoveredPathCircleInfo: {
id: number
index: number
item: paper.Path.Circle
} | null = null
const clearHoveredBufferPathHighlight = () => {
if (hoveredBufferPathInfo) {
hoveredBufferPathInfo.clearHighlight()
}
hoveredBufferPathInfo = null
}
const startMiddleMousePan = (event?: MouseEvent) => {
if (!event || event.button !== 1) return false
@@ -510,6 +753,8 @@ export const getShowContextMenuPosition = (
}
const DOUBLE_CLICK_TOLERANCE_PX = 8
const PAN_DRAG_THRESHOLD_PX = 4
const BUFFER_HOVER_STICKY_MS = 180
const getPaperDoubleClickTolerance = (
coordinateSpace: "project" | "local" = "project"
@@ -525,6 +770,37 @@ const getPaperDoubleClickTolerance = (
return projectTolerance
}
const getPaperDragThreshold = (
coordinateSpace: "project" | "local" = "project"
) => {
const viewZoom = usePaperStore.getState().paperScope?.view.zoom || 1
const projectThreshold = PAN_DRAG_THRESHOLD_PX / viewZoom
if (coordinateSpace === "local") {
const groupScale = usePaperStore.getState().group?.scaling.x || 1
return projectThreshold / groupScale
}
return projectThreshold
}
const showDeletePointLimitNotification = (
type: "polygon" | "brush" | "point"
) => {
const messageMap = {
polygon: "当前对象至少保留3个点无法继续删除",
brush: "当前对象至少保留2个点无法继续删除",
point: "当前对象至少保留1个点无法继续删除",
}
notifications.show({
id: "label-delete-point-limit",
color: "yellow",
autoClose: 1500,
message: messageMap[type],
})
}
const isPaperDoubleClick = (
event: MouseEvent,
lastPoint: paper.Point | null | undefined,
@@ -668,6 +944,7 @@ export const usePaperStore = create<PaperState>((set) => ({
let panTool = paperPanTool
let lastPoint: paper.Point
let isChanged: boolean = false
let hasExceededPanDragThreshold = false
// const handleSelected = (path: paper.Path) => {
// // path.blendMode = "subtract";
@@ -805,6 +1082,96 @@ export const usePaperStore = create<PaperState>((set) => ({
.updateSelectedPath(useBottomToolsStore.getState().activeImage, "ADD")
}
const getPanHitTolerance = (baseTolerance = 8) =>
usePaperStore.getState().paperScope
? baseTolerance / useTopToolsStore.getState().scale
: 0
false && getPanHitTolerance
const createPanHitOptions = (
tolerance: number,
match: (hit: paper.HitResult) => boolean
) => ({
segments: true,
stroke: true,
curves: true,
fill: true,
guides: false,
tolerance,
match,
})
const resolvePanHitResult = (projectPoint: paper.Point) => {
const group = usePaperStore.getState().group
if (!group) return null
const hoveredCircleInfo = hoveredPathCircleInfo?.item.parent
? hoveredPathCircleInfo
: null
if (hoveredCircleInfo) {
const pointHit = group.hitTest(
projectPoint,
createPanHitOptions(0, (hit) => {
return (
hit.item.data?.type === "pathCircle" &&
hit.item.data?.id === hoveredCircleInfo.id &&
hit.item.data?.index === hoveredCircleInfo.index
)
})
)
if (pointHit) return pointHit
}
const hoveredBufferInfo = hoveredBufferPathInfo?.path.parent
? hoveredBufferPathInfo
: null
if (hoveredBufferInfo) {
const edgeHit = group.hitTest(
projectPoint,
createPanHitOptions(0, (hit) => {
return (
hit.item.data?.type === "pathBuff" &&
hit.item.data?.id === hoveredBufferInfo.id &&
hit.item.data?.index === hoveredBufferInfo.index
)
})
)
if (edgeHit) return edgeHit
}
return group.hitTest(
projectPoint,
createPanHitOptions(0, (hit) =>
["rectangle", "polygon", "brush", "point"].includes(
hit.item.data?.type || ""
)
)
)
}
const clearCurrentSelection = () => {
let shouldClearSupport = false
usePaperStore
.getState()
.group!.getItems({
data: { selected: true },
})
.forEach((item) => {
if (item.data.selected) {
item.data.selected = false
shouldClearSupport = true
}
})
if (shouldClearSupport) {
usePaperSupportStore.getState().clearAll()
}
handleCancelSelect()
}
panTool.onMouseDown = (e: { event: MouseEvent; point: paper.Point }) => {
if (startMiddleMousePan(e.event)) return
if (pressCtrl) return
@@ -824,18 +1191,16 @@ export const usePaperStore = create<PaperState>((set) => ({
point,
"local"
)
hasExceededPanDragThreshold = false
let hitResult = usePaperStore.getState().group?.hitTest(e.point, {
segments: true,
stroke: true,
curves: true,
fill: true,
guide: false,
tolerance: usePaperStore.getState().paperScope
? 8 / useTopToolsStore.getState().scale
: 0,
})
let hitResult = resolvePanHitResult(e.point)
console.log("hitResult", hitResult)
const hoveredBufferInfo = hoveredBufferPathInfo?.path.parent
? hoveredBufferPathInfo
: null
const hoveredCircleInfo = hoveredPathCircleInfo?.item.parent
? hoveredPathCircleInfo
: null
let lastTagDataId = -1
if (activePath?.data.id) {
lastTagDataId = activePath.data.id
@@ -941,35 +1306,26 @@ export const usePaperStore = create<PaperState>((set) => ({
if (pressShift) {
} else {
// not press shift
let selectedItems = usePaperStore.getState().group!.getItems({
data: { selected: true },
})
if (selectedItems && selectedItems.length) {
selectedItems.forEach((item) => {
if (item.data.selected) {
let itemHitResult = item.hitTest(point, {
segments: true,
stroke: true,
curves: true,
fill: true,
guide: false,
tolerance: usePaperStore.getState().paperScope
? 8 / usePaperStore.getState().paperScope!.view.zoom
: 0,
})
const selectedIds =
useObjectStore.getState().selectedPath[
useBottomToolsStore.getState().activeImage
] || []
const hitId = hitResult?.item?.data?.id
const keepHoveredPointSelection =
!!hoveredCircleInfo &&
selectedIds.length === 1 &&
selectedIds[0] === hoveredCircleInfo.id
const keepHoveredEdgeSelection =
!!hoveredBufferInfo &&
selectedIds.length === 1 &&
selectedIds[0] === hoveredBufferInfo.id
const keepCurrentSelection =
keepHoveredPointSelection ||
keepHoveredEdgeSelection ||
(hitId && selectedIds.length === 1 && selectedIds[0] === hitId)
if (!itemHitResult) {
item.data.selected = false
usePaperSupportStore.getState().clearAll()
handleCancelSelect()
}
}
// item.data.selected = false;
})
} else {
handleCancelSelect()
if (!keepCurrentSelection) {
clearCurrentSelection()
}
}
@@ -1208,6 +1564,56 @@ export const usePaperStore = create<PaperState>((set) => ({
id: data.id,
})
}
} else if (
isDoubleClick &&
hoveredBufferInfo &&
hoveredBufferInfo.id === activePath?.data?.id &&
["polygon", "brush", "point"].includes(activePath?.data?.type || "")
) {
let currentPath: paper.Path | null = null
if (activePath instanceof paper.CompoundPath) {
currentPath =
(activePath.children as paper.Path[]).find(
(item) => item.data.selected
) || null
} else if (activePath instanceof paper.Path) {
currentPath = activePath
}
if (currentPath) {
hitIndex = hoveredBufferInfo.index
const insertPoint = currentPath.getNearestPoint(point)
currentPath.insert(hitIndex + 1, insertPoint)
usePaperSupportStore.getState().handleBufferPaths(currentPath)
usePaperSupportStore.getState().handleCircles(currentPath)
isChanged = true
panMode = "stroke"
hitIndex = hitIndex + 1
if (currentPath.data.type === "brush") {
usePaperSupportStore.getState().handleMask(currentPath)
}
if (currentPath.data.type === "point") {
usePaperSupportStore.getState().handleText(currentPath)
let circles = usePaperStore.getState().group!.getItems({
data: {
id: currentPath.data.id,
type: "circle",
},
}) as paper.Path[]
currentPath.segments.forEach((segment, index) => {
let findIndex = circles.findIndex((circle) => {
return circle.contains(segment.point)
})
circles[index].data = Object.assign({}, circles[index]?.data, {
text: findIndex + 1,
})
})
usePaperSupportStore.getState().handleBufferPaths(currentPath)
usePaperSupportStore.getState().handleCircles(currentPath)
}
}
} else if (hitResult && hitResult.type === "fill") {
if (
hitResult &&
@@ -1242,28 +1648,30 @@ export const usePaperStore = create<PaperState>((set) => ({
// doubleclick
if (isDoubleClick) {
if (activePath instanceof paper.CompoundPath) {
;(activePath.children as paper.Path[]).forEach((item) => {
const childHitResult = item.hitTest(point, {
segments: true,
stroke: true,
curves: true,
fill: true,
guide: false,
tolerance: usePaperStore.getState().paperScope
? 8 / usePaperStore.getState().paperScope!.view.zoom
: 0,
})
if (childHitResult) {
if (childHitResult.location.point) {
item.insert(hitIndex + 1, childHitResult.location.point)
usePaperSupportStore.getState().handleBufferPaths(item)
usePaperSupportStore.getState().handleCircles(item)
isChanged = true
panMode = "stroke"
hitIndex = hitIndex + 1
}
}
})
const selectedChild =
(activePath.children as paper.Path[]).find(
(item) => item.data.selected
) ||
(activePath.children as paper.Path[]).reduce(
(nearest, item) => {
if (!nearest) return item
return item.getNearestPoint(point).getDistance(point) <
nearest.getNearestPoint(point).getDistance(point)
? item
: nearest
},
null as paper.Path | null
)
if (selectedChild) {
const insertPoint = selectedChild.getNearestPoint(point)
selectedChild.insert(hitIndex + 1, insertPoint)
usePaperSupportStore.getState().handleBufferPaths(selectedChild)
usePaperSupportStore.getState().handleCircles(selectedChild)
isChanged = true
panMode = "stroke"
hitIndex = hitIndex + 1
}
} else {
activePath.insert(hitIndex + 1, hitResult.location.point)
activePath?.data?.type === "point" &&
@@ -1346,8 +1754,16 @@ export const usePaperStore = create<PaperState>((set) => ({
let checkLength = 3
if (item?.data?.type === "brush") checkLength = 2
if (item?.data?.type === "point") checkLength = 1
if (item.segments && item.segments.length <= checkLength)
if (item.segments && item.segments.length <= checkLength) {
if (
item?.data?.type === "polygon" ||
item?.data?.type === "brush" ||
item?.data?.type === "point"
) {
showDeletePointLimitNotification(item.data.type)
}
return
}
;(childHitResult.item as paper.Path).removeSegment(hitIndex)
usePaperSupportStore
.getState()
@@ -1367,8 +1783,16 @@ export const usePaperStore = create<PaperState>((set) => ({
if (
activePath.segments &&
activePath.segments.length <= checkLength
)
) {
if (
activePath?.data?.type === "polygon" ||
activePath?.data?.type === "brush" ||
activePath?.data?.type === "point"
) {
showDeletePointLimitNotification(activePath.data.type)
}
return
}
activePath.removeSegment(hitIndex)
usePaperSupportStore.getState().handleBufferPaths(activePath)
@@ -1517,6 +1941,20 @@ export const usePaperStore = create<PaperState>((set) => ({
point = usePaperStore.getState().group!.globalToLocal(e.point)
}
if (
activePath &&
["fill", "stroke", "segment"].includes(panMode) &&
!hasExceededPanDragThreshold
) {
const dragDistance = e.point.getDistance(e.downPoint)
if (dragDistance < getPaperDragThreshold()) return
hasExceededPanDragThreshold = true
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "move"
}
}
let movePath = (path: paper.Path) => {
const currentGroupScaling = usePaperStore.getState().group!.scaling
const delta = {
@@ -1719,6 +2157,10 @@ export const usePaperStore = create<PaperState>((set) => ({
panTool.onMouseUp = (_e: paper.ToolEvent) => {
if (stopMiddleMousePan()) return
if (useTopToolsStore.getState().isView) return
hasExceededPanDragThreshold = false
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "default"
}
let newDrawPath: paper.Path | paper.CompoundPath | null = null
@@ -3311,8 +3753,14 @@ export const usePaperStore = create<PaperState>((set) => ({
if (startMiddleMousePan(e.event)) return
if (e.event.button === 2) {
e.event.preventDefault()
hidePaperContextMenu()
undoPolygonPoint()
if (polygon || points.length) {
hidePaperContextMenu()
undoPolygonPoint()
return
}
if (!trySelectPaperObjectByPoint(e.point)) {
hidePaperContextMenu()
}
return
}
if (e.event.button !== 0) return
@@ -3894,8 +4342,18 @@ export const usePaperStore = create<PaperState>((set) => ({
let rect: paper.Path.Rectangle | null
let rectangleTool = paperRectangleTool
rectangleTool.onMouseDown = (e: { event: MouseEvent }) => {
rectangleTool.onMouseDown = (e: {
event: MouseEvent
point: paper.Point
}) => {
if (startMiddleMousePan(e.event)) return
if (e.event.button === 2) {
e.event.preventDefault()
if (!trySelectPaperObjectByPoint(e.point)) {
hidePaperContextMenu()
}
return
}
if (e.event && e.event.button !== 2)
useOtherToolsStore.getState().setShowContextMenu({
showContextMenu: false,
@@ -4244,8 +4702,14 @@ export const usePaperStore = create<PaperState>((set) => ({
if (startMiddleMousePan(e.event)) return
if (e.event.button === 2) {
e.event.preventDefault()
hidePaperContextMenu()
undoBrushPoint()
if (myPath || points.length) {
hidePaperContextMenu()
undoBrushPoint()
return
}
if (!trySelectPaperObjectByPoint(e.point)) {
hidePaperContextMenu()
}
return
}
if (e.event.button !== 0) return
@@ -4715,8 +5179,14 @@ export const usePaperStore = create<PaperState>((set) => ({
if (startMiddleMousePan(e.event)) return
if (e.event.button === 2) {
e.event.preventDefault()
hidePaperContextMenu()
undoPoint()
if (myPath || points.length) {
hidePaperContextMenu()
undoPoint()
return
}
if (!trySelectPaperObjectByPoint(e.point)) {
hidePaperContextMenu()
}
return
}
if (e.event.button !== 0) return
@@ -5199,12 +5669,28 @@ export const usePaperStore = create<PaperState>((set) => ({
)
circle.scale(newScale / currentScaling)
circle.onMouseEnter = (_e: any) => {
hoveredPathCircleInfo = {
id,
index,
item: circle,
}
clearHoveredBufferPathHighlight()
circle.strokeWidth = 4
circle.fillColor = new paper.Color("#FFF")
if (
usePaperStore.getState().mode === "pan" &&
usePaperStore.getState().el
) {
usePaperStore.getState().el!.style.cursor = "move"
}
}
circle.onMouseLeave = (_e: any) => {
if (hoveredPathCircleInfo?.item === circle) {
hoveredPathCircleInfo = null
}
circle.strokeWidth = 2
circle.fillColor = color
syncPaperCursorByMode()
}
return circle
},
@@ -5217,8 +5703,8 @@ export const usePaperStore = create<PaperState>((set) => ({
segments: [item.point, item.next],
strokeColor: color, // 设置为透明,以便不可见
strokeScaling: false,
strokeWidth: 4, // 设置一个较大的strokeWidth作为缓冲区
strokeCap: "butt",
strokeWidth: 6, // 适度放宽 hover 区域,但避免干扰对象外默认点击
strokeCap: "round",
onSelect: function (event: { stopPropagation: () => void }) {
event.stopPropagation() // 阻止事件冒泡
},
@@ -5232,10 +5718,54 @@ export const usePaperStore = create<PaperState>((set) => ({
// bufferPath.segments[0].handleIn = item.handleIn;
// bufferPath.segments[1].handleOut = item.handleOut;
let edge: paper.Path | null
let clearTimer: ReturnType<typeof setTimeout> | null = null
const cancelScheduledClear = () => {
if (clearTimer) {
clearTimeout(clearTimer)
clearTimer = null
}
}
const clearHighlight = () => {
cancelScheduledClear()
edge?.remove()
edge = null
if (hoveredBufferPathInfo?.path === bufferPath) {
hoveredBufferPathInfo = null
}
if (
usePaperStore.getState().el &&
!hoveredPathCircleInfo?.item.parent &&
!hoveredBufferPathInfo
) {
syncPaperCursorByMode()
}
}
const scheduleClear = () => {
cancelScheduledClear()
clearTimer = setTimeout(() => {
clearHighlight()
}, BUFFER_HOVER_STICKY_MS)
}
// 鼠标移入时高亮显示
bufferPath.onMouseEnter = function (event: {
stopPropagation: () => void
stopPropagation?: () => void
}) {
if (hoveredPathCircleInfo?.item.parent) {
event.stopPropagation?.()
return
}
cancelScheduledClear()
hoveredBufferPathInfo = {
id,
index,
path: bufferPath,
clearHighlight,
scheduleClear,
cancelScheduledClear,
}
if (edge && !edge.parent) {
edge = null
}
if (!edge) {
edge = new paper.Path(
Object.assign(
@@ -5246,27 +5776,33 @@ export const usePaperStore = create<PaperState>((set) => ({
bufferPath.segments[1].point,
],
strokeColor: "white", // 初始颜色与原始路径相同
strokeWidth: 2,
strokeWidth: 3,
strokeScaling: false,
strokeCap: "butt",
strokeCap: "round",
},
{
data: { index, type: "pathBuff" },
data: { index, type: "pathBuffHover" },
}
)
)
// edge.bringToFront();
event.stopPropagation() // 阻止事件冒泡
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "crosshair"
}
}
event.stopPropagation?.() // 阻止事件冒泡
}
// 鼠标移出时取消高亮
bufferPath.onMouseLeave = function (event: {
stopPropagation: () => void
stopPropagation?: () => void
}) {
edge?.remove()
edge = null
event.stopPropagation() // 阻止事件冒泡
if (hoveredBufferPathInfo?.path === bufferPath) {
hoveredBufferPathInfo.scheduleClear()
} else {
scheduleClear()
}
event.stopPropagation?.() // 阻止事件冒泡
}
return bufferPath
},
@@ -5280,42 +5816,31 @@ export const usePaperStore = create<PaperState>((set) => ({
switch (modeParam) {
case "pan":
usePaperStore.getState().panTool?.activate()
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "default"
}
syncPaperCursorByMode(modeParam)
break
case "polygon":
usePaperStore.getState().polygonTool?.activate()
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "crosshair"
}
syncPaperCursorByMode(modeParam)
break
case "rectangle":
usePaperStore.getState().rectangleTool?.activate()
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "crosshair"
}
syncPaperCursorByMode(modeParam)
break
case "brush":
usePaperStore.getState().brushTool?.activate()
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "crosshair"
}
syncPaperCursorByMode(modeParam)
break
case "point":
usePaperStore.getState().pointTool?.activate()
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "crosshair"
}
syncPaperCursorByMode(modeParam)
break
case "support":
usePaperStore.getState().supportTool?.activate()
if (usePaperStore.getState().el) {
usePaperStore.getState().el!.style.cursor = "crosshair"
}
syncPaperCursorByMode(modeParam)
break
default:
usePaperStore.getState().viewTool?.activate()
syncPaperCursorByMode(modeParam)
break
}
},

View File

@@ -10,6 +10,8 @@ interface TopToolsState {
// 标注页面是否仅查看
isView: boolean
setIsView: (val: boolean) => void
renderMode: boolean
setRenderMode: (val: boolean) => void
// 绘制模式
editMode: boolean
setEditMode: (val: boolean) => void
@@ -81,6 +83,7 @@ interface TopToolsState {
const initialTopToolsState = {
isView: false,
renderMode: false,
editMode: false,
pressA: false,
imageFilter: {
@@ -114,6 +117,12 @@ export const useTopToolsStore = create<TopToolsState>()(
...state,
isView: val,
})),
renderMode: initialTopToolsState.renderMode,
setRenderMode: (val: boolean) =>
set((state: TopToolsState) => ({
...state,
renderMode: val,
})),
editMode: initialTopToolsState.editMode,
setEditMode: (val: boolean) =>
set((state: TopToolsState) => ({

View File

@@ -195,11 +195,14 @@ const settingDataTableStyles = {
},
root: {
height: "100%",
maxWidth: "100%",
minWidth: 0,
fontSize: "14px",
color: "#31373D",
borderBottom: "1px solid #E8EDF3",
borderRadius: 10,
overflow: "hidden",
boxSizing: "border-box",
backgroundColor: "#FFFFFF",
},
pagination: {
@@ -307,6 +310,7 @@ export const settingTabsStyles = {
panel: {
flex: 1,
minHeight: 0,
minWidth: 0,
paddingTop: 16,
},
} as const

View File

@@ -26,6 +26,7 @@
"@ffmpeg/util": "^0.12.2",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@mantine/core": "^8.3.1",
"@mantine/dates": "^8.3.18",
"@mantine/form": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@mantine/modals": "^8.3.10",