feat(shortcut): ,/

This commit is contained in:
zhangheng
2026-04-03 18:06:52 +08:00
parent bb727d52be
commit e4aae0a7af
2 changed files with 551 additions and 26 deletions

View File

@@ -12,6 +12,7 @@ import ScaleComponent from "./components/ScaleComponent"
import { Box, Button, Flex, LoadingOverlay, Stack } from "@mantine/core"
import { notifications, showNotification } from "@mantine/notifications"
import paper from "paper"
import { getLabelResult, getServerImage } from "./api/label"
import {
Comment,
@@ -56,8 +57,8 @@ import {
import { useTopToolsStore } from "./useTopToolsStore"
import { findGroupKey } from "./util"
import { safeClone } from "./utils/clone"
import { toggleObjectHideByShortcut } from "./utils/objectVisibility"
import { labelTypeMap } from "./utils/constants"
import { toggleObjectHideByShortcut } from "./utils/objectVisibility"
// Splitter component - will need custom implementation or alternative
@@ -1511,6 +1512,395 @@ const LabelPage = ({
)
}, [resolveSelectedObjectContext])
const rerenderActiveImageByLabel = useCallback(
(
imageId: string,
imageLabel: Map<string, [number, any[], any, any[]][]>,
selectedIds: number[]
) => {
const paperGroup = usePaperStore.getState().group
if (paperGroup) {
paperGroup
.getItems({})
.filter((item) => item.data.id)
.forEach((item) => {
item.remove()
})
for (const [categoryId] of imageLabel.entries()) {
paperContainerRef.current?.renderPolygons?.(categoryId, imageLabel)
}
}
useObjectStore.setState((state) => ({
...state,
selectedPath: {
...state.selectedPath,
[imageId]: selectedIds,
},
}))
},
[]
)
const removeObjectFromGroupMap = useCallback(
(imageId: string, id: number) => {
const nextPathGroupMap = safeClone(
useRightToolsStore.getState().pathGroupMap
)
const imageGroupMap = nextPathGroupMap.get(imageId)
if (!imageGroupMap) return
for (let [groupId, pathIds] of imageGroupMap.entries()) {
const nextPathIds = pathIds.filter((pathId) => pathId !== id)
if (nextPathIds.length > 1) {
imageGroupMap.set(groupId, nextPathIds)
} else {
imageGroupMap.delete(groupId)
}
}
if (!imageGroupMap.size) nextPathGroupMap.delete(imageId)
useRightToolsStore.getState().setPathGroupMap(nextPathGroupMap)
},
[]
)
const hasShapeSegments = useCallback((shape: any) => {
if (!shape) return false
if (shape instanceof paper.CompoundPath) {
return (shape.children as paper.Path[]).some(
(child) => child.segments?.length
)
}
if (shape instanceof paper.Path) {
return !!shape.segments?.length
}
return false
}, [])
const createWorkingPolygonShape = useCallback((items: any[]) => {
const compound = items.find((item) => item instanceof paper.CompoundPath)
if (compound) {
return (compound as paper.CompoundPath).clone({ insert: false }) as
| paper.Path
| paper.CompoundPath
}
const paths = items.filter(
(item) => item instanceof paper.Path
) as paper.Path[]
if (!paths.length) return null
if (paths.length === 1) {
return paths[0].clone({ insert: false }) as
| paper.Path
| paper.CompoundPath
}
const merged = new paper.CompoundPath({ insert: false })
paths.forEach((path) => {
merged.addChild(path.clone({ insert: false }))
})
return merged as paper.Path | paper.CompoundPath
}, [])
const collectPolygonPoints = useCallback(
(shape: paper.Path | paper.CompoundPath) => {
const polygonPoints: any[] = []
const polygonHollowPoints: any[] = []
const collectPath = (path: paper.Path) => {
if (!path.segments?.length) return
const pathData = JSON.parse(path.exportJSON({ precision: 20 }))
if (path.clockwise) {
polygonPoints.push(pathData[1]?.segments)
} else {
polygonHollowPoints.push(pathData[1]?.segments)
}
}
if (shape instanceof paper.CompoundPath) {
;(shape.children as paper.Path[]).forEach((path) => collectPath(path))
} else {
collectPath(shape)
}
return { polygonPoints, polygonHollowPoints }
},
[]
)
const applySelectedPolygonOverlap = useCallback(
(mode: "subtract" | "unite") => {
const selectedContext = resolveSelectedObjectContext()
if (!selectedContext) {
notifications.show({
color: "yellow",
message: "请先选中单个对象",
})
return
}
if (selectedContext.objectType !== "polygon") {
notifications.show({
color: "yellow",
message:
mode === "subtract"
? "镂空快捷键仅支持 polygon 类型对象"
: "融合快捷键仅支持 polygon/brush 类型对象",
})
return
}
const group = usePaperStore.getState().group
if (!group) return
const targetItems = group
.getItems({ data: { id: selectedContext.objectId } })
.filter((item) => item.data?.type === "polygon")
if (!targetItems.length) return
let workingShape = createWorkingPolygonShape(targetItems)
if (!workingShape || !hasShapeSegments(workingShape)) return
const otherPolygonPaths: paper.Path[] = []
group.children.forEach((item: any) => {
if (
!item.visible ||
!item.data?.id ||
item.data.id === selectedContext.objectId ||
item.data.type !== "polygon"
)
return
if (item instanceof paper.Path) {
if (item.segments?.length) otherPolygonPaths.push(item)
} else if (item instanceof paper.CompoundPath) {
;(item.children as paper.Path[]).forEach((child) => {
if (child.segments?.length) otherPolygonPaths.push(child)
})
}
})
let changed = false
for (const eachPath of otherPolygonPaths) {
if (!workingShape || !hasShapeSegments(workingShape)) break
const eachPathClone = eachPath.clone({ insert: false }) as paper.Path
const intersectionPath = (workingShape as any).intersect(
eachPathClone,
{
insert: false,
}
) as paper.Path | paper.CompoundPath
const hasIntersection = hasShapeSegments(intersectionPath)
intersectionPath?.remove()
if (!hasIntersection) {
eachPathClone.remove()
continue
}
const nextShape: paper.Path | paper.CompoundPath =
mode === "subtract"
? ((workingShape as any).subtract(eachPathClone, {
insert: false,
}) as paper.Path | paper.CompoundPath)
: ((workingShape as any).unite(eachPathClone, {
insert: false,
}) as paper.Path | paper.CompoundPath)
eachPathClone.remove()
workingShape?.remove()
workingShape = nextShape
changed = true
}
if (!changed) {
workingShape?.remove()
return
}
if (!workingShape) return
const raster = usePaperStore.getState().rasterPath
if (raster && hasShapeSegments(workingShape)) {
const clippedShape = (workingShape as any).intersect(raster, {
insert: false,
trace: false,
}) as paper.Path | paper.CompoundPath
workingShape?.remove()
workingShape = clippedShape
}
if (!workingShape) return
const { polygonPoints, polygonHollowPoints } =
collectPolygonPoints(workingShape)
workingShape?.remove()
const nextLabel = safeClone(useLabelStore.getState().label)
const imageLabel = nextLabel.get(selectedContext.imageId)
if (!imageLabel) return
const operationPaths = safeClone(
imageLabel.get(selectedContext.operationId) || []
)
const selectedIndex = operationPaths.findIndex(
(item) => item[0] === selectedContext.objectId
)
if (selectedIndex === -1) return
const currentTuple = operationPaths[selectedIndex]
if (polygonPoints.length) {
operationPaths[selectedIndex] = [
selectedContext.objectId,
polygonPoints,
safeClone(currentTuple?.[2] || {}),
polygonHollowPoints,
]
imageLabel.set(selectedContext.operationId, operationPaths)
} else {
imageLabel.set(
selectedContext.operationId,
operationPaths.filter((item) => item[0] !== selectedContext.objectId)
)
removeObjectFromGroupMap(
selectedContext.imageId,
selectedContext.objectId
)
}
useLabelStore.getState().setLabel(nextLabel)
useLabelStore.getState().pushStateStack(nextLabel)
rerenderActiveImageByLabel(
selectedContext.imageId,
imageLabel,
polygonPoints.length ? [selectedContext.objectId] : []
)
},
[
collectPolygonPoints,
createWorkingPolygonShape,
hasShapeSegments,
removeObjectFromGroupMap,
rerenderActiveImageByLabel,
resolveSelectedObjectContext,
]
)
const applySelectedBrushIntersectUnite = useCallback(() => {
const selectedContext = resolveSelectedObjectContext()
if (!selectedContext) {
notifications.show({
color: "yellow",
message: "请先选中单个对象",
})
return
}
if (selectedContext.objectType !== "brush") {
notifications.show({
color: "yellow",
message: "融合快捷键仅支持 polygon/brush 类型对象",
})
return
}
const group = usePaperStore.getState().group
if (!group) return
const selectedBrushPaths = group
.getItems({ data: { id: selectedContext.objectId } })
.filter((item) => item.data?.type === "brush")
.flatMap((item) => {
if (item instanceof paper.CompoundPath)
return item.children as paper.Path[]
if (item instanceof paper.Path) return [item]
return []
})
.filter((path) => path.segments?.length)
if (!selectedBrushPaths.length) return
const otherBrushPathMap = new Map<number, paper.Path[]>()
group.children.forEach((item: any) => {
if (
!item.visible ||
!item.data?.id ||
item.data.id === selectedContext.objectId ||
item.data.type !== "brush"
)
return
const targetId = Number(item.data.id)
const currentPaths = otherBrushPathMap.get(targetId) || []
if (item instanceof paper.CompoundPath) {
otherBrushPathMap.set(targetId, [
...currentPaths,
...(item.children as paper.Path[]),
])
} else if (item instanceof paper.Path) {
otherBrushPathMap.set(targetId, [...currentPaths, item])
}
})
const intersectingIds = new Set<number>()
for (const [otherId, paths] of otherBrushPathMap.entries()) {
const hasIntersection = paths.some((otherPath) => {
return selectedBrushPaths.some((selectedPath) => {
if (!selectedPath.bounds.intersects(otherPath.bounds)) return false
return selectedPath.getIntersections(otherPath).length > 0
})
})
if (hasIntersection) intersectingIds.add(otherId)
}
if (!intersectingIds.size) return
const nextLabel = safeClone(useLabelStore.getState().label)
const imageLabel = nextLabel.get(selectedContext.imageId)
if (!imageLabel) return
const selectedOperationPaths = safeClone(
imageLabel.get(selectedContext.operationId) || []
)
const selectedIndex = selectedOperationPaths.findIndex(
(item) => item[0] === selectedContext.objectId
)
if (selectedIndex === -1) return
const selectedTuple = selectedOperationPaths[selectedIndex]
const mergedPoints = safeClone(selectedTuple?.[1] || [])
const pointSet = new Set(
mergedPoints.map((pointItem) => JSON.stringify(pointItem))
)
let appendedCount = 0
imageLabel.forEach((paths) => {
paths.forEach((item) => {
if (!intersectingIds.has(item[0])) return
;(item[1] || []).forEach((pathPoints: any) => {
const key = JSON.stringify(pathPoints)
if (pointSet.has(key)) return
pointSet.add(key)
mergedPoints.push(safeClone(pathPoints))
appendedCount++
})
})
})
if (!appendedCount) return
selectedOperationPaths[selectedIndex] = [
selectedContext.objectId,
mergedPoints,
safeClone(selectedTuple?.[2] || {}),
safeClone(selectedTuple?.[3] || []),
]
imageLabel.set(selectedContext.operationId, selectedOperationPaths)
useLabelStore.getState().setLabel(nextLabel)
useLabelStore.getState().pushStateStack(nextLabel)
rerenderActiveImageByLabel(selectedContext.imageId, imageLabel, [
selectedContext.objectId,
])
}, [rerenderActiveImageByLabel, resolveSelectedObjectContext])
const handleMergeSelectedObjects = useCallback(() => {
const activeImage = useBottomToolsStore.getState().activeImage
const selectedIds =
@@ -1655,7 +2045,7 @@ const LabelPage = ({
const mode = usePaperStore.getState().mode
const lowerKey = e.key.toLowerCase()
if (e.repeat && ["a", "d", "h"].includes(lowerKey)) return
if (e.repeat && ["a", "d", "h", ",", "."].includes(lowerKey)) return
if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "h") {
e.preventDefault()
@@ -1675,6 +2065,47 @@ const LabelPage = ({
return
}
if (
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
[",", ""].includes(e.key)
) {
e.preventDefault()
applySelectedPolygonOverlap("subtract")
return
}
if (
!e.ctrlKey &&
!e.metaKey &&
!e.altKey &&
[".", "。"].includes(e.key)
) {
e.preventDefault()
const selectedContext = resolveSelectedObjectContext()
if (!selectedContext) {
notifications.show({
color: "yellow",
message: "请先选中单个对象",
})
return
}
if (selectedContext.objectType === "polygon") {
applySelectedPolygonOverlap("unite")
return
}
if (selectedContext.objectType === "brush") {
applySelectedBrushIntersectUnite()
return
}
notifications.show({
color: "yellow",
message: "融合快捷键仅支持 polygon/brush 类型对象",
})
return
}
if (e.altKey && e.key.toLowerCase() === "x") {
e.preventDefault()
if (!isView) topRef.current?.commitTaskByActionKey?.()
@@ -1896,6 +2327,8 @@ const LabelPage = ({
element?.removeEventListener("mousemove", mousemove)
}
}, [
applySelectedBrushIntersectUnite,
applySelectedPolygonOverlap,
editMode,
enterSelectedObjectDrawMode,
focusInput,
@@ -1911,6 +2344,7 @@ const LabelPage = ({
setPressA,
setScale,
setShift,
resolveSelectedObjectContext,
])
useEffect(() => {

View File

@@ -2866,6 +2866,96 @@ export const usePaperStore = create<PaperState>((set) => ({
return nextData
}
const getSegmentPoint = (segment: any): [number, number] | null => {
if (
Array.isArray(segment) &&
segment.length >= 2 &&
typeof segment[0] === "number" &&
typeof segment[1] === "number"
) {
return [segment[0], segment[1]]
}
if (
Array.isArray(segment) &&
Array.isArray(segment[0]) &&
segment[0].length >= 2 &&
typeof segment[0][0] === "number" &&
typeof segment[0][1] === "number"
) {
return [segment[0][0], segment[0][1]]
}
if (
segment?.point &&
typeof segment.point.x === "number" &&
typeof segment.point.y === "number"
) {
return [segment.point.x, segment.point.y]
}
if (
segment?.point &&
Array.isArray(segment.point) &&
segment.point.length >= 2 &&
typeof segment.point[0] === "number" &&
typeof segment.point[1] === "number"
) {
return [segment.point[0], segment.point[1]]
}
return null
}
const normalizePolygonFromSegments = (segments: any[]) => {
const polygon = (segments || [])
.map((segment) => getSegmentPoint(segment))
.filter((point): point is [number, number] => !!point)
if (polygon.length > 1) {
const [firstX, firstY] = polygon[0]
const [lastX, lastY] = polygon[polygon.length - 1]
if (
Math.abs(firstX - lastX) < 0.000001 &&
Math.abs(firstY - lastY) < 0.000001
) {
polygon.pop()
}
}
return polygon
}
const isPointInPolygon = (
point: [number, number],
polygon: [number, number][]
) => {
if (polygon.length < 3) return false
const [x, y] = point
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i]
const [xj, yj] = polygon[j]
const dy = yj - yi
if (yi > y !== yj > y) {
const crossX = ((xj - xi) * (y - yi)) / (dy || 0.000001) + xi
if (x < crossX) inside = !inside
}
}
return inside
}
const getHollowFlagsByContainment = (allSegments: any[][]) => {
const polygons = allSegments.map((segments) =>
normalizePolygonFromSegments(segments)
)
return polygons.map((polygon, index) => {
if (polygon.length < 3) return false
const samplePoint = polygon[0]
let containDepth = 0
polygons.forEach((otherPolygon, otherIndex) => {
if (otherIndex === index || otherPolygon.length < 3) return
if (isPointInPolygon(samplePoint, otherPolygon)) containDepth += 1
})
return containDepth % 2 === 1
})
}
const handleSelectedPolygonSubtract = (draftPath: paper.Path) => {
const activeImage = useBottomToolsStore.getState().activeImage
const selectedIds =
@@ -2915,36 +3005,37 @@ export const usePaperStore = create<PaperState>((set) => ({
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 nextPaths =
nextPolygon instanceof paper.CompoundPath
? (nextPolygon.children as paper.Path[])
: ([nextPolygon] as paper.Path[])
const nextPathSegments = nextPaths.map((path) => {
if (!path.segments?.length) return null
const pathData = JSON.parse(path.exportJSON({ precision: 20 }))
if (path.clockwise) {
points.push(pathData[1]?.segments)
} else {
hollowPoints.push(pathData[1]?.segments)
}
}
return pathData?.[1]?.segments || null
})
const hollowFlags = getHollowFlagsByContainment(
nextPathSegments.map((segments) => segments || [])
)
if (nextPolygon instanceof paper.CompoundPath) {
;(nextPolygon.children as paper.Path[]).forEach((child) => {
collectPathPoints(child)
})
} else {
collectPathPoints(nextPolygon)
nextPolygon.data = Object.assign({}, targetData)
}
nextPaths.forEach((path, index) => {
const segments = nextPathSegments[index]
if (!segments?.length) return
const isHollowPolygon = !!hollowFlags[index]
path.data = Object.assign({}, targetData, {
isHollowPolygon,
})
if (isHollowPolygon) {
hollowPoints.push(segments)
} else {
points.push(segments)
}
})
const operationId = (
targetData.operationId ||