diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx index f13b8d5..7637ae4 100644 --- a/components/label/LabelNossr.tsx +++ b/components/label/LabelNossr.tsx @@ -56,6 +56,7 @@ import { import { useTopToolsStore } from "./useTopToolsStore" import { findGroupKey } from "./util" import { safeClone } from "./utils/clone" +import { toggleObjectHideByShortcut } from "./utils/objectVisibility" import { labelTypeMap } from "./utils/constants" // Splitter component - will need custom implementation or alternative @@ -233,9 +234,11 @@ const LabelPage = ({ const { editMode, setEditMode, + pressA, setPressA, activeOperation, setActiveOperation, + setDrawOption, showObjectList, showGroupList, showTaskList, @@ -1357,6 +1360,12 @@ const LabelPage = ({ }, [editMode, setDrawType]) const { setShift, focusInput } = useKeyEventStore() + const drawAction = useKeyboardStore((state) => state.drawAction) + const drawShortcutSnapshotRef = useRef<{ + activeOperation: string + drawOption: "default" | "intersect" | "unite" + editMode: boolean + } | null>(null) const handleShortcut = useCallback( (key: string, ctrl: boolean) => { @@ -1376,6 +1385,132 @@ const LabelPage = ({ [getOperationSchema, setActiveOperation] ) + const resolveSelectedObjectContext = useCallback(() => { + const imageId = useBottomToolsStore.getState().activeImage + const selectedIds = useObjectStore.getState().selectedPath[imageId] || [] + if (selectedIds.length !== 1) return null + + const objectId = selectedIds[0] + const imageLabel = useLabelStore.getState().label.get(imageId) + if (!imageLabel) return null + + let operationId = "" + for (const [categoryId, paths] of imageLabel.entries()) { + if (paths.some((path) => path[0] === objectId)) { + operationId = categoryId + break + } + } + if (!operationId) return null + + const selectedItem = usePaperStore.getState().getItemById(objectId) + if (!selectedItem?.data?.type) return null + + return { + imageId, + objectId, + operationId, + objectType: selectedItem.data.type as string, + } + }, []) + + const restoreDrawShortcutSnapshot = useCallback(() => { + useKeyboardStore.getState().setDrawAction("none") + + const snapshot = drawShortcutSnapshotRef.current + if (!snapshot) return + + setActiveOperation(snapshot.activeOperation) + setDrawOption(snapshot.drawOption) + setEditMode(snapshot.editMode) + drawShortcutSnapshotRef.current = null + }, [setActiveOperation, setDrawOption, setEditMode]) + + const enterSelectedObjectDrawMode = useCallback( + (mode: "add" | "subtract") => { + const selectedContext = resolveSelectedObjectContext() + if (!selectedContext) { + notifications.show({ + color: "yellow", + message: "请先选中单个对象", + }) + return + } + + if ( + mode === "add" && + !["polygon", "brush"].includes(selectedContext.objectType) + ) { + notifications.show({ + color: "yellow", + message: "新增区域仅支持 polygon/brush 类型对象", + }) + return + } + + if (mode === "subtract" && selectedContext.objectType !== "polygon") { + notifications.show({ + color: "yellow", + message: "删除区域仅支持 polygon 类型对象", + }) + return + } + + if (!drawShortcutSnapshotRef.current) { + const topState = useTopToolsStore.getState() + drawShortcutSnapshotRef.current = { + activeOperation: topState.activeOperation, + drawOption: topState.drawOption, + editMode: topState.editMode, + } + } + + setActiveOperation(selectedContext.operationId) + setEditMode(true) + setDrawOption("default") + useKeyboardStore.getState().setDrawAction(mode) + setPressA(true) + + if (!useTopToolsStore.getState().pressA) { + notifications.show({ + color: "yellow", + message: "当前对象无法进入区域编辑模式", + }) + restoreDrawShortcutSnapshot() + } + }, + [ + resolveSelectedObjectContext, + restoreDrawShortcutSnapshot, + setActiveOperation, + setDrawOption, + setEditMode, + setPressA, + ] + ) + + const handleToggleHideSelectedObject = useCallback(() => { + const selectedContext = resolveSelectedObjectContext() + if (!selectedContext) { + notifications.show({ + color: "yellow", + message: "请先选中单个对象", + }) + return + } + + const currentHideStatus = + useObjectStore.getState().pathStatus[ + selectedContext.imageId + selectedContext.objectId + ]?.HIDE || false + toggleObjectHideByShortcut( + selectedContext.imageId, + selectedContext.operationId, + selectedContext.objectId, + !currentHideStatus + ) + }, [resolveSelectedObjectContext]) + const handleMergeSelectedObjects = useCallback(() => { const activeImage = useBottomToolsStore.getState().activeImage const selectedIds = @@ -1518,6 +1653,27 @@ const LabelPage = ({ console.log(e.key) const mode = usePaperStore.getState().mode + const lowerKey = e.key.toLowerCase() + + if (e.repeat && ["a", "d", "h"].includes(lowerKey)) return + + if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "h") { + e.preventDefault() + handleToggleHideSelectedObject() + return + } + + if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "a") { + e.preventDefault() + enterSelectedObjectDrawMode("add") + return + } + + if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "d") { + e.preventDefault() + enterSelectedObjectDrawMode("subtract") + return + } if (e.altKey && e.key.toLowerCase() === "x") { e.preventDefault() @@ -1561,11 +1717,6 @@ const LabelPage = ({ paperContainerRef.current.handleCreatePathGroup() } - if (e.key === "a") { - e.preventDefault() - setPressA(true) - } - if (e.key === "Control") { e.preventDefault() useKeyboardStore.getState().setCtrl(true) @@ -1746,19 +1897,27 @@ const LabelPage = ({ } }, [ editMode, + enterSelectedObjectDrawMode, focusInput, getOperationSchema, + handleToggleHideSelectedObject, handleMergeSelectedObjects, handleShortcut, isView, projectDetail?.label_type, setEditMode, setGroupScale, + setDrawOption, setPressA, setScale, setShift, ]) + useEffect(() => { + if (drawAction === "none" || pressA) return + restoreDrawShortcutSnapshot() + }, [drawAction, pressA, restoreDrawShortcutSnapshot]) + const setGlobalLoading = useCallback((flag: boolean) => { setLoading(flag) }, []) @@ -1766,6 +1925,8 @@ const LabelPage = ({ // 切换图片时清空复制保存的数组 useEffect(() => { useKeyboardStore.getState().setCopyDataIds([]) + useKeyboardStore.getState().setDrawAction("none") + drawShortcutSnapshotRef.current = null }, [activeImage]) const handleBeforeUnload = (e: any) => { diff --git a/components/label/components/RightObjectTools.tsx b/components/label/components/RightObjectTools.tsx index f1e9aa9..0d8934b 100644 --- a/components/label/components/RightObjectTools.tsx +++ b/components/label/components/RightObjectTools.tsx @@ -42,6 +42,7 @@ import { getShowContextMenuPosition, usePaperStore } from "../usePaperStore" import { usePaperSupportStore } from "../usePaperSupportStore" import { useTopToolsStore } from "../useTopToolsStore" import { getSubAttrStatus } from "../util" +import { toggleObjectHideByShortcut } from "../utils/objectVisibility" import { labelTypeMap } from "../utils/constants" interface RightObjectToolsComponentProps { @@ -379,33 +380,6 @@ const RightObjectTools: React.FC = (props) => { }) } - const getTagVisibility = (tagCategory?: string) => { - const { showTags, showTagsConfigs } = useTopToolsStore.getState() - if (!showTags) return false - if (tagCategory === "mask") return showTagsConfigs.includes("外框") - if (tagCategory === "point_text") return showTagsConfigs.includes("点ID") - return true - } - - const syncObjectItemsVisibility = useCallback( - (objectId: number, hide: boolean) => { - const sameIdItems = getItemsById(objectId) - if (!sameIdItems?.length) return - sameIdItems.forEach((item) => { - if (hide) { - item.visible = false - return - } - if (item.data?.type === "tag") { - item.visible = getTagVisibility(item.data?.tag_category) - return - } - item.visible = true - }) - }, - [getItemsById] - ) - const operationHide = useCallback( (operationId: string, hide: boolean) => { updateOperationStatus(activeImage + operationId, "HIDE", hide) @@ -414,51 +388,17 @@ const RightObjectTools: React.FC = (props) => { .get(activeImage) ?.get(operationId) ?.map((child) => { - updatePathStatus(activeImage + child[0], "HIDE", hide) - syncObjectItemsVisibility(child[0], hide) - // 隐藏时清空选中状态 - if (hide) setSelectedItemFalse(child[0]) + toggleObjectHideByShortcut(activeImage, operationId, child[0], hide) }) }, - [ - activeImage, - storeLabel, - syncObjectItemsVisibility, - updateOperationStatus, - updatePathStatus, - ] + [activeImage, storeLabel, updateOperationStatus] ) const objectHide = useCallback( (operationId: string, objectId: number, hide: boolean) => { - if (hide) { - updatePathStatus(activeImage + objectId, "HIDE", hide) - syncObjectItemsVisibility(objectId, hide) - // 隐藏时清空选中状态 - setSelectedItemFalse(objectId) - let childrenHideStatus = storeLabel - .get(activeImage) - ?.get(operationId) - ?.map((child) => { - return getItemById(child[0])!.visible - }) - if (childrenHideStatus?.findIndex((bool) => bool === true) === -1) { - updateOperationStatus(activeImage + operationId, "HIDE", hide) - } - } else { - updateOperationStatus(activeImage + operationId, "HIDE", hide) - updatePathStatus(activeImage + objectId, "HIDE", hide) - syncObjectItemsVisibility(objectId, hide) - } + toggleObjectHideByShortcut(activeImage, operationId, objectId, hide) }, - [ - activeImage, - getItemById, - storeLabel, - syncObjectItemsVisibility, - updateOperationStatus, - updatePathStatus, - ] + [activeImage] ) // const renderObjectName = useCallback((name: string, input: string) => { diff --git a/components/label/components/TopTools.tsx b/components/label/components/TopTools.tsx index 8ef91a4..2677979 100644 --- a/components/label/components/TopTools.tsx +++ b/components/label/components/TopTools.tsx @@ -87,6 +87,7 @@ import { import { useBackUrlStore, usePermissionStore } from "../store/auth" import { useBottomToolsStore } from "../useBottomToolsStore" import { useDescToolsStore } from "../useDescToolsStore" +import { useKeyboardStore } from "../useKeyBoardStore" import { usePaperStore } from "../usePaperStore" import { useRightToolsStore } from "../useRightToolsStore" import { useIntervalStore, useLabelTimeStore } from "../useTimerStore" @@ -1531,6 +1532,7 @@ const TopTools = ( }, [activeOperation, mode]) const operationSchema = getOperationSchema(activeOperation) + const drawAction = useKeyboardStore((state) => state.drawAction) const subAttrForm = ( @@ -1645,6 +1647,12 @@ const TopTools = ( const iconStyle = { width: 20, height: 20 } if (mode === "support") return if (editMode) { + if (pressA && drawAction === "subtract") { + return + } + if (pressA && drawAction === "add") { + return + } return pressA ? ( ) : ( @@ -1653,7 +1661,7 @@ const TopTools = ( } else { return } - }, [mode, editMode, pressA]) + }, [mode, editMode, pressA, drawAction]) const showTagConfigContainer = useMemo(() => { const configs = ["ID", "类别", "外框", "点ID", "子属性", "属性值"] diff --git a/components/label/useKeyBoardStore.tsx b/components/label/useKeyBoardStore.tsx index 8b2f4fe..a9e26bb 100644 --- a/components/label/useKeyBoardStore.tsx +++ b/components/label/useKeyBoardStore.tsx @@ -5,6 +5,8 @@ interface KeyboardStore { setCtrl: (v: boolean) => void shift: boolean setShift: (v: boolean) => void + drawAction: "none" | "add" | "subtract" + setDrawAction: (mode: "none" | "add" | "subtract") => void copyDataIds: number[] // ctrl + c 复制数据 setCopyDataIds: (ids: number[]) => void } @@ -12,8 +14,10 @@ interface KeyboardStore { export const useKeyboardStore = create((set) => ({ ctrl: false, shift: false, + drawAction: "none", copyDataIds: [], setCtrl: (v) => set((state) => ({ ...state, ctrl: v })), setShift: (v) => set((state) => ({ ...state, shift: v })), + setDrawAction: (mode) => set((state) => ({ ...state, drawAction: mode })), setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })), })) diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index e946436..e11ff42 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -12,6 +12,7 @@ import { useObjectStore, } from "./store" import { useBottomToolsStore } from "./useBottomToolsStore" +import { useKeyboardStore } from "./useKeyBoardStore" import { useOtherToolsStore } from "./useOtherToolsStore" import { usePaperSupportStore } from "./usePaperSupportStore" import { useRightToolsStore } from "./useRightToolsStore" @@ -2855,6 +2856,139 @@ export const usePaperStore = create((set) => ({ }) } + // paper path data里可能带函数(例如事件回调), 不能直接structuredClone + const getSafePathData = (data: any) => { + const nextData: Record = {} + Object.entries(data || {}).forEach(([key, value]) => { + if (typeof value === "function") return + nextData[key] = value + }) + return nextData + } + + const handleSelectedPolygonSubtract = (draftPath: paper.Path) => { + const activeImage = useBottomToolsStore.getState().activeImage + const selectedIds = + useObjectStore.getState().selectedPath[activeImage] || [] + if (selectedIds.length !== 1) return false + + const selectedId = selectedIds[0] + const targetShapes = usePaperStore + .getState() + .getItemsById(selectedId) + .filter((item) => item.data?.type === "polygon") + + const targetShape = + targetShapes.find((item) => item instanceof paper.CompoundPath) || + targetShapes.find( + (item) => item instanceof paper.Path && !item.data?.isHollowPolygon + ) || + targetShapes.find((item) => item instanceof paper.Path) + + if (!targetShape) return false + + let clippedDraft: paper.Path | paper.CompoundPath | null = + draftPath.intersect(usePaperStore.getState().rasterPath!, { + insert: false, + trace: false, + }) as paper.Path | paper.CompoundPath + + draftPath.remove() + + const draftHasSegments = + clippedDraft instanceof paper.CompoundPath + ? (clippedDraft.children as paper.Path[]).some( + (child) => child.segments?.length + ) + : clippedDraft.segments?.length + if (!draftHasSegments) { + clippedDraft?.remove() + clippedDraft = null + return false + } + + const targetData = getSafePathData(targetShape.data || {}) + let nextPolygon = ( + targetShape as paper.Path | paper.CompoundPath + ).subtract(clippedDraft) as paper.Path | paper.CompoundPath + clippedDraft?.remove() + clippedDraft = null + targetShape.remove() + + if (nextPolygon instanceof paper.CompoundPath) { + nextPolygon.data = Object.assign({}, targetData) + ;(nextPolygon.children as paper.Path[]).forEach((child) => { + child.data = Object.assign({}, targetData, { + isHollowPolygon: !child.clockwise, + }) + }) + } else { + nextPolygon.data = Object.assign({}, targetData) + } + + const points: any[] = [] + const hollowPoints: any[] = [] + const collectPathPoints = (path: paper.Path) => { + if (!path.segments?.length) return + const pathData = JSON.parse(path.exportJSON({ precision: 20 })) + if (path.clockwise) { + points.push(pathData[1]?.segments) + } else { + hollowPoints.push(pathData[1]?.segments) + } + } + + if (nextPolygon instanceof paper.CompoundPath) { + ;(nextPolygon.children as paper.Path[]).forEach((child) => { + collectPathPoints(child) + }) + } else { + collectPathPoints(nextPolygon) + } + + const operationId = ( + targetData.operationId || + useTopToolsStore.getState().activeOperation || + "" + ).toString() + if (!operationId) { + nextPolygon.remove() + return false + } + const prevDetail = safeClone( + useLabelStore + .getState() + .getLabelDetailDataByKeys(activeImage, operationId, selectedId) || + initialDetail + ) + + if (points.length) { + useLabelStore + .getState() + .setOperation(activeImage, operationId, [ + selectedId, + points, + prevDetail, + hollowPoints, + ]) + useObjectStore + .getState() + .updateSelectedPath(activeImage, "ADD", selectedId) + } else { + nextPolygon.remove() + useLabelStore + .getState() + .deleteOperation(activeImage, operationId, selectedId) + useObjectStore.getState().updateSelectedPath(activeImage, "ADD") + const nextLabel = safeClone(useLabelStore.getState().label) + useLabelStore.getState().setLabel(nextLabel) + useLabelStore.getState().pushStateStack(nextLabel) + } + + forceUpdate() + return true + } + polygonTool.onMouseDown = (e: { event: MouseEvent point: paper.Point @@ -2897,35 +3031,83 @@ export const usePaperStore = create((set) => ({ // 初始化 compoundPolygon compoundPolygon = new paper.Path() + if (useKeyboardStore.getState().drawAction === "subtract") { + ;(polygon as paper.Path).closePath() + handleSelectedPolygonSubtract(polygon as paper.Path) + + selectedCircles.forEach((item) => { + item.remove() + }) + selectedCircles = [] + clearPolygonMagnet() + useTopToolsStore.getState().setPressA(false) + draftId = null + lastPoint = null + lastTime = 0 + points = [] + polygon = null + return + } + // pressA 模式下添加多边形 if (useTopToolsStore.getState().pressA) { let ids = useObjectStore.getState().selectedPath[ useBottomToolsStore.getState().activeImage ] + const selectedId = ids?.[0] + if (!selectedId) { + resetPolygonDraft() + return + } let paths = usePaperStore.getState().getItemsById(ids[0]) let oldPolygon = polygon // 闭合绘制路径 oldPolygon.closePath() + const polygonPaths = paths.filter( + (path) => path.data?.type === "polygon" + ) + const prevCompoundPath = + (polygonPaths.find( + (path) => path instanceof paper.CompoundPath + ) as paper.CompoundPath | undefined) || + ((polygonPaths.find( + (path) => path.parent instanceof paper.CompoundPath + )?.parent as paper.CompoundPath | undefined) ?? + undefined) - if (paths.length === 1) { - const prevPath = paths[0] + const basePathData = getSafePathData( + polygonPaths.find((path) => !!path.data)?.data || {} + ) + oldPolygon.data = Object.assign({}, basePathData, oldPolygon.data, { + id: selectedId, + type: "polygon", + isHollowPolygon: !oldPolygon.clockwise, + }) + + if (prevCompoundPath) { + // 已是多区域对象, 直接追加 child + prevCompoundPath.addChild(oldPolygon) + prevCompoundPath.data = Object.assign( + {}, + getSafePathData(prevCompoundPath.data || {}), + basePathData, + { id: selectedId, type: "polygon" } + ) + polygon = prevCompoundPath + } else { + const prevPaths = polygonPaths.filter( + (path): path is paper.Path => path instanceof paper.Path + ) polygon = usePaperStore.getState().addCompoundPath( renderCompoundPath, - [prevPath, oldPolygon] as paper.Path[], + [...prevPaths, oldPolygon] as paper.Path[], { // option ...usePaperStore.getState().toolOption, }, - { ...oldPolygon.data, id: prevPath?.data.id } + { ...basePathData, ...oldPolygon.data, id: selectedId } ) - } else if (paths.length > 1) { - const prevPath = paths.filter( - (path) => path instanceof paper.CompoundPath - )[0] - // 直接将当前路径添加到原有父路径 - prevPath.addChild(oldPolygon) - polygon = prevPath } } diff --git a/components/label/useTopToolsStore.ts b/components/label/useTopToolsStore.ts index 8ca3dbf..9087c0c 100644 --- a/components/label/useTopToolsStore.ts +++ b/components/label/useTopToolsStore.ts @@ -136,7 +136,7 @@ export const useTopToolsStore = create()( } let path = usePaperStore.getState().getItemById(ids[0]) if (path && path.data.type !== "rectangle") { - let flag = val && usePaperStore.getState().mode !== "rectangle" + let flag = val set((state: TopToolsState) => ({ ...state, diff --git a/components/label/utils/objectVisibility.ts b/components/label/utils/objectVisibility.ts new file mode 100644 index 0000000..1cfe34f --- /dev/null +++ b/components/label/utils/objectVisibility.ts @@ -0,0 +1,71 @@ +import paper from "paper" +import { useLabelStore, useObjectStore } from "../store" +import { usePaperStore } from "../usePaperStore" +import { useTopToolsStore } from "../useTopToolsStore" + +const getTagVisibility = (tagCategory?: string) => { + const { showTags, showTagsConfigs } = useTopToolsStore.getState() + if (!showTags) return false + if (tagCategory === "mask") return showTagsConfigs.includes("外框") + if (tagCategory === "point_text") return showTagsConfigs.includes("点ID") + return true +} + +const setSelectedItemFalse = (id: number) => { + if (!id) return + const sameIdItems = usePaperStore.getState().getItemsById(id) + sameIdItems.forEach((item) => { + if (item.data.type === "mask" || item.data.type === "text") { + item.remove() + } + if (item.data.selected) item.data.selected = false + }) +} + +const syncObjectItemsVisibility = (objectId: number, hide: boolean) => { + const sameIdItems = usePaperStore.getState().getItemsById(objectId) + if (!sameIdItems?.length) return + sameIdItems.forEach((item) => { + if (hide) { + item.visible = false + return + } + if (item.data?.type === "tag") { + item.visible = getTagVisibility(item.data?.tag_category) + return + } + item.visible = true + }) +} + +export const toggleObjectHideByShortcut = ( + imageId: string, + operationId: string, + objectId: number, + hide: boolean +) => { + const { updatePathStatus, updateOperationStatus } = useObjectStore.getState() + const storeLabel = useLabelStore.getState().label + + if (hide) { + updatePathStatus(imageId + objectId, "HIDE", hide) + syncObjectItemsVisibility(objectId, hide) + setSelectedItemFalse(objectId) + + const operationChildren = storeLabel.get(imageId)?.get(operationId) || [] + const hasVisibleChild = operationChildren.some((child) => { + const item = usePaperStore + .getState() + .getItemById(child[0]) as paper.Item | null + return !!item?.visible + }) + if (!hasVisibleChild) { + updateOperationStatus(imageId + operationId, "HIDE", hide) + } + return + } + + updateOperationStatus(imageId + operationId, "HIDE", hide) + updatePathStatus(imageId + objectId, "HIDE", hide) + syncObjectItemsVisibility(objectId, hide) +}