feat(shortcut): h/a/d

This commit is contained in:
zhangheng
2026-04-03 17:00:09 +08:00
parent b6ae2a6a29
commit bb727d52be
7 changed files with 449 additions and 83 deletions

View File

@@ -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) => {

View File

@@ -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<RightObjectToolsComponentProps> = (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<RightObjectToolsComponentProps> = (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) => {

View File

@@ -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 = (
<Box w={256}>
@@ -1645,6 +1647,12 @@ const TopTools = (
const iconStyle = { width: 20, height: 20 }
if (mode === "support") return <Paintbrush style={iconStyle} />
if (editMode) {
if (pressA && drawAction === "subtract") {
return <CopyMinus style={iconStyle} />
}
if (pressA && drawAction === "add") {
return <CopyPlus style={iconStyle} />
}
return pressA ? (
<CirclePlus style={iconStyle} />
) : (
@@ -1653,7 +1661,7 @@ const TopTools = (
} else {
return <MousePointer style={iconStyle} />
}
}, [mode, editMode, pressA])
}, [mode, editMode, pressA, drawAction])
const showTagConfigContainer = useMemo(() => {
const configs = ["ID", "类别", "外框", "点ID", "子属性", "属性值"]

View File

@@ -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<KeyboardStore>((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 })),
}))

View File

@@ -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<PaperState>((set) => ({
})
}
// paper path data里可能带函数(例如事件回调), 不能直接structuredClone
const getSafePathData = (data: any) => {
const nextData: Record<string, any> = {}
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<PaperState>((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
}
}

View File

@@ -136,7 +136,7 @@ export const useTopToolsStore = create<TopToolsState>()(
}
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,

View File

@@ -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)
}