diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index e56bcc5..36d3f6d 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -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" @@ -319,6 +319,26 @@ const hidePaperContextMenu = () => { } 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 +530,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 +547,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 +721,7 @@ export const usePaperStore = create((set) => ({ let panTool = paperPanTool let lastPoint: paper.Point let isChanged: boolean = false + let hasExceededPanDragThreshold = false // const handleSelected = (path: paper.Path) => { // // path.blendMode = "subtract"; @@ -810,6 +864,8 @@ export const usePaperStore = create((set) => ({ ? baseTolerance / useTopToolsStore.getState().scale : 0 + false && getPanHitTolerance + const createPanHitOptions = ( tolerance: number, match: (hit: paper.HitResult) => boolean @@ -827,13 +883,39 @@ export const usePaperStore = create((set) => ({ const group = usePaperStore.getState().group if (!group) return null - const supportHit = group.hitTest( - projectPoint, - createPanHitOptions(getPanHitTolerance(), (hit) => - ["pathCircle", "pathBuff"].includes(hit.item.data?.type || "") + 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 (supportHit) return supportHit + 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, @@ -886,9 +968,16 @@ export const usePaperStore = create((set) => ({ point, "local" ) + hasExceededPanDragThreshold = false 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 @@ -999,8 +1088,18 @@ export const usePaperStore = create((set) => ({ 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 = - hitId && selectedIds.length === 1 && selectedIds[0] === hitId + keepHoveredPointSelection || + keepHoveredEdgeSelection || + (hitId && selectedIds.length === 1 && selectedIds[0] === hitId) if (!keepCurrentSelection) { clearCurrentSelection() @@ -1242,6 +1341,56 @@ export const usePaperStore = create((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 && @@ -1276,28 +1425,30 @@ export const usePaperStore = create((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" && @@ -1380,8 +1531,16 @@ export const usePaperStore = create((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() @@ -1401,8 +1560,16 @@ export const usePaperStore = create((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) @@ -1551,6 +1718,20 @@ export const usePaperStore = create((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 = { @@ -1753,6 +1934,10 @@ export const usePaperStore = create((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 @@ -5233,12 +5418,27 @@ export const usePaperStore = create((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().el) { + usePaperStore.getState().el!.style.cursor = "move" + } } circle.onMouseLeave = (_e: any) => { + if (hoveredPathCircleInfo?.item === circle) { + hoveredPathCircleInfo = null + } circle.strokeWidth = 2 circle.fillColor = color + if (usePaperStore.getState().el) { + usePaperStore.getState().el!.style.cursor = "default" + } } return circle }, @@ -5251,8 +5451,8 @@ export const usePaperStore = create((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() // 阻止事件冒泡 }, @@ -5266,10 +5466,54 @@ export const usePaperStore = create((set) => ({ // bufferPath.segments[0].handleIn = item.handleIn; // bufferPath.segments[1].handleOut = item.handleOut; let edge: paper.Path | null + let clearTimer: ReturnType | 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 + ) { + usePaperStore.getState().el!.style.cursor = "default" + } + } + 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( @@ -5280,27 +5524,33 @@ export const usePaperStore = create((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 },