From b050af02670d5560edba011fac881ca9cf25ad30 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Sat, 18 Apr 2026 21:13:17 +0800 Subject: [PATCH 1/5] fix(label): selected path add point --- 5168f53_polygon_brush_point_edit_fix.md | 91 ++++++++++++++++++++++ components/label/usePaperStore.ts | 59 +++++++++++--- components/label/utils/objectVisibility.ts | 33 +++++--- 3 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 5168f53_polygon_brush_point_edit_fix.md diff --git a/5168f53_polygon_brush_point_edit_fix.md b/5168f53_polygon_brush_point_edit_fix.md new file mode 100644 index 0000000..c8fdf90 --- /dev/null +++ b/5168f53_polygon_brush_point_edit_fix.md @@ -0,0 +1,91 @@ +# 5168f53 回归分析与修复记录 + +## 问题描述 +- 提交:`5168f539e0dbf84d6a1625d10ef98149fb21ffe6` +- 现象:`polygon` / `brush` / `point` 类型对象在被选中后,无法通过鼠标悬浮边触发边高亮,也无法通过双击边新增点。 + +## usePaperStore 关键链路梳理 +1. 选中对象 + - 入口主要在 `components/label/usePaperStore.ts` 的 `initPanTool -> panTool.onMouseDown` + - 通过 `resolvePanHitResult()` 命中对象、辅助边(`pathBuff`)或辅助点(`pathCircle`) + - 选中后会调用: + - `usePaperSupportStore.handleBufferPaths()` 生成可 hover / 可双击的辅助边 + - `usePaperSupportStore.handleCircles()` 生成可拖拽节点 + - `usePaperSupportStore.handleMask()` / `handleText()` 生成刷子/点类型附加辅助层 + +2. 悬浮边高亮 + - `usePaperSupportStore.handleBufferPaths()` 会遍历 path 的 segment,调用 `usePaperStore.getBuffPath()` 为每条边创建 `pathBuff` + - `getBuffPath()` 中给 `bufferPath` 绑定 `onMouseEnter / onMouseLeave` + - 悬浮时通过额外创建 `pathBuffHover` 白色描边来实现高亮 + - 同时把当前 hover 信息记入 `hoveredBufferPathInfo` + +3. 双击边新增点 + - 仍然在 `panTool.onMouseDown` + - 双击判定由 `isPaperDoubleClick()` 完成 + - 命中 `pathBuff` 或存在 `hoveredBufferPathInfo` 时,会走 `stroke` 分支或 `hoveredBufferInfo` 分支 + - 最终通过 `path.insert(hitIndex + 1, point)` 新增节点 + +4. 右侧/外部选择对象后的恢复链路 + - `components/label/utils/objectVisibility.ts` 的 `syncSelectedObjectsVisualState()` 负责根据 `selectedPath` 重新恢复画布上的选中态 + - 该链路也必须正确恢复: + - 选中的实际可编辑 path + - 辅助边 `pathBuff` + - 辅助点 `pathCircle` + - 否则对象虽“被选中”,但无法进入边 hover / 双击增点链路 + +## 回归根因 +### 根因 1:辅助边被改成完全透明,导致命中/hover 链路失效 +- 在 `getBuffPath()` 中,`pathBuff.strokeColor` 被改成了完全透明的颜色 +- 这会导致 `pathBuff` 在某些情况下不再稳定触发 hover / hit 行为 +- 后果: + - `hoveredBufferPathInfo` 无法建立 + - 边高亮不出现 + - 双击新增点依赖的 `pathBuff` 命中链路失效 + +### 根因 2:选中态恢复时,没有稳定选中“真正可编辑的 path” +- `polygon` / `brush` / `point` 可能存在 `CompoundPath + child Path` 的结构 +- 之前恢复选中态时,更多是“对象被选中”,但没有保证“某个可编辑 child path 被明确选中并生成辅助边/辅助点” +- 后果: + - 外部选中/恢复选中后,缺少 `pathBuff` + - 即使对象看起来是选中状态,也无法 hover 边、双击增点 + +## 修复方案 +### 已做 +- [x] 在 `components/label/usePaperStore.ts` 中新增 `ensureEditablePaperPathSelected()` + - 统一从同一对象 id 下挑出一个真实可编辑的 `paper.Path` + - 排除 `isHollowPolygon` + - 保证只有该可编辑 path 维持 `selected = true` + +- [x] 修复 `applyPaperSelectedPath()` + - 对 `polygon` / `brush` / `point` 不再只依赖当前 item 是否刚好是 `paper.Path` + - 改为统一拿到可编辑 path 后恢复: + - `handleBufferPaths` + - `handleCircles` + - `handleMask` + - `handleText` + +- [x] 修复 `components/label/utils/objectVisibility.ts` 中的 `applySelectedItemVisual()` + - 保证右侧面板/隐藏恢复/重绘恢复时,同样能恢复到真实可编辑 path 的辅助编辑态 + +- [x] 修复 `getBuffPath()` 的命中可用性 + - 不再使用 alpha 为 `0` 的完全透明描边 + - 改为极低透明度(`alpha = 0.001`)以保留 hover/hit 能力,同时视觉上保持不可见 + +- [x] 完成静态校验 + - 执行 `pnpm exec tsc --noEmit` + - 当前补丁未引入新的 TypeScript 类型错误 + +## 待验证 +- [ ] 直接点击已存在 `polygon` 对象后,悬浮边是否出现高亮 +- [ ] 直接点击已存在 `polygon` 对象后,双击边是否成功新增点 +- [ ] 直接点击已存在 `brush` 对象后,悬浮边是否出现高亮 +- [ ] 直接点击已存在 `brush` 对象后,双击边是否成功新增点 +- [ ] 直接点击已存在 `point` 对象后,悬浮边是否出现高亮 +- [ ] 直接点击已存在 `point` 对象后,双击边是否成功新增点 +- [ ] 从右侧对象列表切换选中后,上述三类对象是否仍可编辑 +- [ ] 多区域/CompoundPath 对象时,是否总能命中当前可编辑子路径 + +## 涉及文件 +- `components/label/usePaperStore.ts` +- `components/label/utils/objectVisibility.ts` +- `5168f53_polygon_brush_point_edit_fix.md` diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index caff1dd..3192f3d 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -899,6 +899,28 @@ const isPaperObjectType = (type: unknown) => typeof type === "string" && PAPER_OBJECT_TYPES.includes(type as (typeof PAPER_OBJECT_TYPES)[number]) +export const ensureEditablePaperPathSelected = (objectId: number) => { + const objectPaths = usePaperStore + .getState() + .getItemsById(objectId) + .filter( + (item): item is paper.Path => + item instanceof paper.Path && isPaperObjectType(item.data?.type) + ) + + if (!objectPaths.length) return null + + const editablePaths = objectPaths.filter((item) => !item.data?.isHollowPolygon) + const activePath = + editablePaths.find((item) => item.data?.selected) || editablePaths[0] || null + + objectPaths.forEach((item) => { + item.data.selected = item === activePath + }) + + return activePath +} + const isPaperContextHitType = (type: unknown) => typeof type === "string" && PAPER_CONTEXT_HIT_TYPES.includes( @@ -1001,6 +1023,13 @@ const resetPaperShapeFillColor = (item: paper.Item) => { } const applyPaperSelectedPath = (item: paper.Path | paper.CompoundPath) => { + const objectId = Number(item.data?.id) + const editablePath = Number.isFinite(objectId) + ? ensureEditablePaperPathSelected(objectId) + : item instanceof paper.Path && !item.data?.isHollowPolygon + ? item + : null + if (item.data.type === "rectangle" && item instanceof paper.Path) { item.data.selected = true usePaperSupportStore.getState().handleBufferPaths(item) @@ -1008,19 +1037,25 @@ const applyPaperSelectedPath = (item: paper.Path | paper.CompoundPath) => { 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) + if (editablePath) { + usePaperSupportStore.getState().handleMask(editablePath) + usePaperSupportStore.getState().handleBufferPaths(editablePath) + usePaperSupportStore.getState().handleCircles(editablePath) } - } else if (item.data.type === "point" && item instanceof paper.Path) { + } else if (item.data.type === "point") { item.data.selected = true - usePaperSupportStore.getState().handleMask(item) - usePaperSupportStore.getState().handleText(item) - usePaperSupportStore.getState().handleBufferPaths(item) - usePaperSupportStore.getState().handleCircles(item) + if (editablePath) { + usePaperSupportStore.getState().handleMask(editablePath) + usePaperSupportStore.getState().handleText(editablePath) + usePaperSupportStore.getState().handleBufferPaths(editablePath) + usePaperSupportStore.getState().handleCircles(editablePath) + } } else if (item.data.type === "polygon") { item.data.selected = true + if (editablePath) { + usePaperSupportStore.getState().handleBufferPaths(editablePath) + usePaperSupportStore.getState().handleCircles(editablePath) + } if (!(item instanceof paper.Path) || !item.data?.isHollowPolygon) { item.fillColor = item.data.fillColor || item.fillColor || null } @@ -6653,15 +6688,17 @@ export const usePaperStore = create((set) => ({ item: paper.Segment, index: number, id: number, - _color: any + color: any ) => { if (!item.next) return null + const hitStrokeColor = clonePaperColor(color) || new paper.Color("#fff") + hitStrokeColor.alpha = 0.001 let bufferPath: paper.Path = new paper.Path( Object.assign( { parent: usePaperStore.getState().group!, segments: [item.point, item.next], - strokeColor: new paper.Color(0, 0, 0, 0), // 保留 hit 区域,但本体不可见 + strokeColor: hitStrokeColor, // 透明度不能为 0,否则部分命中/hover 事件会失效 strokeScaling: false, strokeWidth: 6, // 适度放宽 hover 区域,但避免干扰对象外默认点击 strokeCap: "round", diff --git a/components/label/utils/objectVisibility.ts b/components/label/utils/objectVisibility.ts index 21b526b..9b43cf1 100644 --- a/components/label/utils/objectVisibility.ts +++ b/components/label/utils/objectVisibility.ts @@ -1,7 +1,7 @@ import paper from "paper" import { useLabelStore, useObjectStore } from "../store" import { useOtherToolsStore } from "../useOtherToolsStore" -import { usePaperStore } from "../usePaperStore" +import { ensureEditablePaperPathSelected, usePaperStore } from "../usePaperStore" import { usePaperSupportStore } from "../usePaperSupportStore" import { useTopToolsStore } from "../useTopToolsStore" @@ -93,6 +93,13 @@ const applySelectedItemVisual = (item: paper.Item) => { if (!["rectangle", "polygon", "brush", "point"].includes(itemType || "")) return + const objectId = Number(item.data?.id) + const editablePath = Number.isFinite(objectId) + ? ensureEditablePaperPathSelected(objectId) + : item instanceof paper.Path && !item.data?.isHollowPolygon + ? item + : null + if (itemType === "rectangle" && item instanceof paper.Path) { item.data.selected = true usePaperSupportStore.getState().handleBufferPaths(item) @@ -100,19 +107,25 @@ const applySelectedItemVisual = (item: paper.Item) => { item.fillColor = item.data.fillColor || item.fillColor || null } else if (itemType === "brush") { item.data.selected = true - if (item instanceof paper.Path) { - usePaperSupportStore.getState().handleMask(item) - usePaperSupportStore.getState().handleBufferPaths(item) - usePaperSupportStore.getState().handleCircles(item) + if (editablePath) { + usePaperSupportStore.getState().handleMask(editablePath) + usePaperSupportStore.getState().handleBufferPaths(editablePath) + usePaperSupportStore.getState().handleCircles(editablePath) } - } else if (itemType === "point" && item instanceof paper.Path) { + } else if (itemType === "point") { item.data.selected = true - usePaperSupportStore.getState().handleMask(item) - usePaperSupportStore.getState().handleText(item) - usePaperSupportStore.getState().handleBufferPaths(item) - usePaperSupportStore.getState().handleCircles(item) + if (editablePath) { + usePaperSupportStore.getState().handleMask(editablePath) + usePaperSupportStore.getState().handleText(editablePath) + usePaperSupportStore.getState().handleBufferPaths(editablePath) + usePaperSupportStore.getState().handleCircles(editablePath) + } } else if (itemType === "polygon") { item.data.selected = true + if (editablePath) { + usePaperSupportStore.getState().handleBufferPaths(editablePath) + usePaperSupportStore.getState().handleCircles(editablePath) + } if (!(item instanceof paper.Path) || !item.data?.isHollowPolygon) { item.fillColor = item.data.fillColor || item.fillColor || null } From a57b9c4427729668ab2e64726cdcbe8fe4f4bae7 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Sat, 18 Apr 2026 21:18:23 +0800 Subject: [PATCH 2/5] fix(label): line width --- components/label/usePaperStore.ts | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index 3192f3d..b158c07 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -80,6 +80,7 @@ const replacePaperWorkingPolygonShape = ( } syncPaperCompoundPolygonChildrenData(nextShape, phase) + syncPaperObjectStrokeScaling(nextShape) currentShape?.remove() if (!nextShape.parent && originalParent && "insertChild" in originalParent) { @@ -146,6 +147,7 @@ const createPaperPathFromGeometryContour = ({ ) path.strokeColor = clonePaperColor(strokeColor) path.strokeWidth = strokeWidth + path.strokeScaling = false path.fillColor = clonePaperColor(fillColor) path.visible = visible path.opacity = opacity @@ -215,6 +217,7 @@ const createPaperPolygonShapeFromGeometry = ({ compoundPath.data = safeClone(baseData) compoundPath.strokeColor = clonePaperColor(strokeColor) compoundPath.strokeWidth = strokeWidth + compoundPath.strokeScaling = false compoundPath.fillColor = clonePaperColor(fillColor) compoundPath.visible = visible compoundPath.opacity = opacity @@ -899,6 +902,40 @@ const isPaperObjectType = (type: unknown) => typeof type === "string" && PAPER_OBJECT_TYPES.includes(type as (typeof PAPER_OBJECT_TYPES)[number]) +const syncPaperObjectStrokeScaling = ( + item: paper.Item | null | undefined +) => { + if (!(item instanceof paper.Path || item instanceof paper.CompoundPath)) { + return + } + if (!isPaperObjectType(item.data?.type)) { + return + } + + item.strokeScaling = false + + if (item instanceof paper.CompoundPath) { + ;(item.children as paper.Path[]).forEach((child) => { + child.strokeScaling = false + }) + } +} + +const syncAllPaperObjectStrokeScaling = () => { + const group = usePaperStore.getState().group + if (!group) return + + group + .getItems({ + match: (item: paper.Item) => + (item instanceof paper.Path || item instanceof paper.CompoundPath) && + isPaperObjectType(item.data?.type), + }) + .forEach((item) => { + syncPaperObjectStrokeScaling(item) + }) +} + export const ensureEditablePaperPathSelected = (objectId: number) => { const objectPaths = usePaperStore .getState() @@ -6887,6 +6924,8 @@ export const usePaperStore = create((set) => ({ } item.scale(1 / newScale) }) + + syncAllPaperObjectStrokeScaling() } }, getAll: () => { From cad763d2a3642ef6f9d6c32a4bda8592637935e7 Mon Sep 17 00:00:00 2001 From: shay7sev Date: Sat, 18 Apr 2026 21:55:01 +0800 Subject: [PATCH 3/5] feat(tool): help --- components/label/LabelNossr.tsx | 14 +- .../label/components/PaperContainer.tsx | 308 +++++++++++++++++- components/label/components/TopTools.tsx | 22 ++ components/label/useKeyBoardStore.tsx | 73 ++++- 4 files changed, 402 insertions(+), 15 deletions(-) diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx index 21d989f..28721b9 100644 --- a/components/label/LabelNossr.tsx +++ b/components/label/LabelNossr.tsx @@ -2024,10 +2024,22 @@ const LabelPage = ({ const mode = usePaperStore.getState().mode const lowerKey = e.key.toLowerCase() const commandKey = e.ctrlKey || e.metaKey + const isShortcutGuideKey = + e.code === "Slash" || e.key === "?" || e.key === "/" - if (e.repeat && ["a", "b", "d", "h", "m", ",", "."].includes(lowerKey)) + if ( + e.repeat && + ["a", "b", "d", "h", "m", ",", ".", "/", "?"].includes(lowerKey) + ) return + if (!e.ctrlKey && !e.metaKey && !e.altKey && isShortcutGuideKey) { + e.preventDefault() + const keyboardState = useKeyboardStore.getState() + keyboardState.setShowShortcutGuide(!keyboardState.showShortcutGuide) + return + } + if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "m") { e.preventDefault() const topState = useTopToolsStore.getState() diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx index d354a2b..c6856b3 100644 --- a/components/label/components/PaperContainer.tsx +++ b/components/label/components/PaperContainer.tsx @@ -121,6 +121,49 @@ type FrameSwitchGhostState = { animating: boolean } +const SHORTCUT_GUIDE_SECTIONS = [ + { + id: "global", + title: "全局", + items: [ + ["? / /(Shift+/)", "显示或隐藏快捷键说明"], + ["0-9", "切换当前标注类别"], + ["滚轮", "缩放画布"], + ["Ctrl / ⌘ + S", "保存当前标注"], + ["Ctrl / ⌘ + Z", "撤销上一步"], + ["Ctrl / ⌘ + C", "复制当前选中对象"], + ["Ctrl / ⌘ + V", "粘贴到多帧"], + ["Ctrl / ⌘ + F", "合并当前选中对象"], + ["Ctrl / ⌘ + G", "创建对象组"], + ["Alt + X", "提交任务"], + ], + }, + { + id: "edit", + title: "对象编辑", + items: [ + ["Delete", "删除当前选中对象或点"], + ["H", "隐藏当前选中对象 / 全部对象"], + ["M", "切换磁吸模式"], + ["A", "进入选中对象补加绘制"], + ["D", "进入选中对象裁剪绘制"], + ["B", "续画当前选中的 polygon"], + [",", "对选中 polygon 执行相减"], + [".", "对选中 polygon / brush 执行融合"], + ["Esc", "取消当前绘制或退出辅助态"], + ], + }, + { + id: "tool", + title: "专项工具", + items: [ + ["F1", "进入辅助标注 / 在辅助模式下确认"], + ["I", "执行追踪"], + ["P", "QA 文本场景检查错词"], + ], + }, +] as const + const clonePaperPoint = (point: paper.Point | null | undefined) => { if (!point) return null return new paper.Point(point.x, point.y) @@ -183,6 +226,11 @@ const PaperContainer = ( false && updateLoadingFlag const containerRef = useRef(null) const contextMenuRef = useRef(null) + const shortcutGuideCardRef = useRef(null) + const shortcutGuideDragRef = useRef<{ + offsetX: number + offsetY: number + } | null>(null) const [imgSize, setImageSize] = useState<{ clientWidth: number clientHeight: number @@ -451,6 +499,21 @@ const PaperContainer = ( showTags, showTagsConfigs, } = useTopToolsStore() + const showShortcutGuide = useKeyboardStore( + (state) => state.showShortcutGuide + ) + const shortcutGuidePosition = useKeyboardStore( + (state) => state.shortcutGuidePosition + ) + const setShortcutGuidePosition = useKeyboardStore( + (state) => state.setShortcutGuidePosition + ) + const shortcutGuideCollapsedSections = useKeyboardStore( + (state) => state.shortcutGuideCollapsedSections + ) + const toggleShortcutGuideSection = useKeyboardStore( + (state) => state.toggleShortcutGuideSection + ) const { pathStatus: paperPathStatus, @@ -469,8 +532,107 @@ const PaperContainer = ( setContextMenuOperationId, } = useOtherToolsStore() const { renderTag } = useRenderTag() - const [setup, setSetup] = useState(false) + const [isShortcutGuideDragging, setIsShortcutGuideDragging] = useState(false) + + const clampShortcutGuidePosition = useCallback( + (position: { x: number; y: number }) => { + const container = containerRef.current as HTMLDivElement | null + const card = shortcutGuideCardRef.current + if (!container || !card) return position + + const padding = 12 + const maxX = Math.max( + padding, + container.clientWidth - card.offsetWidth - padding + ) + const maxY = Math.max( + padding, + container.clientHeight - card.offsetHeight - padding + ) + + return { + x: Math.min(Math.max(position.x, padding), maxX), + y: Math.min(Math.max(position.y, padding), maxY), + } + }, + [] + ) + + const handleShortcutGuideDragStart = useCallback( + (event: React.MouseEvent) => { + if (event.button !== 0) return + + const container = containerRef.current as HTMLDivElement | null + if (!container) return + + const rect = container.getBoundingClientRect() + shortcutGuideDragRef.current = { + offsetX: event.clientX - rect.left - shortcutGuidePosition.x, + offsetY: event.clientY - rect.top - shortcutGuidePosition.y, + } + setIsShortcutGuideDragging(true) + event.preventDefault() + event.stopPropagation() + }, + [shortcutGuidePosition.x, shortcutGuidePosition.y] + ) + + useEffect(() => { + if (!isShortcutGuideDragging) return + + const handleMouseMove = (event: MouseEvent) => { + const container = containerRef.current as HTMLDivElement | null + const dragState = shortcutGuideDragRef.current + if (!container || !dragState) return + + const rect = container.getBoundingClientRect() + setShortcutGuidePosition( + clampShortcutGuidePosition({ + x: event.clientX - rect.left - dragState.offsetX, + y: event.clientY - rect.top - dragState.offsetY, + }) + ) + } + + const stopDragging = () => { + shortcutGuideDragRef.current = null + setIsShortcutGuideDragging(false) + } + + window.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mouseup", stopDragging) + + return () => { + window.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("mouseup", stopDragging) + } + }, [ + clampShortcutGuidePosition, + isShortcutGuideDragging, + setShortcutGuidePosition, + ]) + + useLayoutEffect(() => { + if (!showShortcutGuide) return + + const nextPosition = clampShortcutGuidePosition(shortcutGuidePosition) + if ( + nextPosition.x !== shortcutGuidePosition.x || + nextPosition.y !== shortcutGuidePosition.y + ) { + setShortcutGuidePosition(nextPosition) + } + }, [ + clampShortcutGuidePosition, + imgSize?.clientHeight, + imgSize?.clientWidth, + setShortcutGuidePosition, + shortcutGuideCollapsedSections, + shortcutGuidePosition, + showShortcutGuide, + ]) + const labelRenderScaleRef = useRef>({}) const renderedImageRef = useRef("") const frameImageCacheRef = useRef>(new Map()) @@ -4105,6 +4267,150 @@ const PaperContainer = ( }} /> )} + {showShortcutGuide && ( + + { + event.stopPropagation() + }} + style={{ + pointerEvents: "auto", + maxHeight: "calc(100% - 24px)", + overflowY: "auto", + background: + "light-dark(rgba(255,255,255,0.78), rgba(24,28,36,0.74))", + borderColor: + "light-dark(rgba(34,139,230,0.18), rgba(114,192,252,0.18))", + backdropFilter: "blur(6px)", + }}> + + + + + + 快捷键说明 + + + 默认靠左显示,可拖动;按 ? 快速开关 + + + + 拖动 + + + + {SHORTCUT_GUIDE_SECTIONS.map((section) => { + const collapsed = !!shortcutGuideCollapsedSections[section.id] + + return ( + + { + toggleShortcutGuideSection(section.id) + }} + style={{ + cursor: "pointer", + userSelect: "none", + }}> + + {section.title} + + + {collapsed ? "展开" : "收起"} + + + {!collapsed && ( + + {section.items.map(([key, description]) => ( + + + {key} + + + {description} + + + ))} + + )} + + ) + })} + + + + )} {contextMenu} state.drawAction) + const showShortcutGuide = useKeyboardStore((state) => state.showShortcutGuide) + const setShowShortcutGuide = useKeyboardStore( + (state) => state.setShowShortcutGuide + ) const subAttrForm = ( @@ -2529,6 +2534,23 @@ const TopTools = ( 对象 + + { + setShowShortcutGuide(!showShortcutGuide) + }}> + + 快捷键 + + diff --git a/components/label/useKeyBoardStore.tsx b/components/label/useKeyBoardStore.tsx index 0dd39f6..868baf5 100644 --- a/components/label/useKeyBoardStore.tsx +++ b/components/label/useKeyBoardStore.tsx @@ -1,4 +1,5 @@ import { create } from "zustand" +import { persist } from "zustand/middleware" export interface SelectedObjectEditTarget { imageId: string @@ -12,6 +13,15 @@ interface KeyboardStore { setCtrl: (v: boolean) => void shift: boolean setShift: (v: boolean) => void + showShortcutGuide: boolean + setShowShortcutGuide: (v: boolean) => void + shortcutGuidePosition: { + x: number + y: number + } + setShortcutGuidePosition: (position: { x: number; y: number }) => void + shortcutGuideCollapsedSections: Record + toggleShortcutGuideSection: (sectionId: string) => void drawAction: "none" | "add" | "subtract" setDrawAction: (mode: "none" | "add" | "subtract") => void selectedObjectEditTarget: SelectedObjectEditTarget | null @@ -20,16 +30,53 @@ interface KeyboardStore { setCopyDataIds: (ids: number[]) => void } -export const useKeyboardStore = create((set) => ({ - ctrl: false, - shift: false, - drawAction: "none", - selectedObjectEditTarget: null, - copyDataIds: [], - setCtrl: (v) => set((state) => ({ ...state, ctrl: v })), - setShift: (v) => set((state) => ({ ...state, shift: v })), - setDrawAction: (mode) => set((state) => ({ ...state, drawAction: mode })), - setSelectedObjectEditTarget: (target) => - set((state) => ({ ...state, selectedObjectEditTarget: target })), - setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })), -})) +export const useKeyboardStore = create()( + persist( + (set) => ({ + ctrl: false, + shift: false, + showShortcutGuide: false, + shortcutGuidePosition: { + x: 16, + y: 16, + }, + shortcutGuideCollapsedSections: {}, + drawAction: "none", + selectedObjectEditTarget: null, + copyDataIds: [], + setCtrl: (v) => set((state) => ({ ...state, ctrl: v })), + setShift: (v) => set((state) => ({ ...state, shift: v })), + setShowShortcutGuide: (v) => + set((state) => ({ ...state, showShortcutGuide: v })), + setShortcutGuidePosition: (position) => + set((state) => ({ + ...state, + shortcutGuidePosition: { + x: Math.round(position.x), + y: Math.round(position.y), + }, + })), + toggleShortcutGuideSection: (sectionId) => + set((state) => ({ + ...state, + shortcutGuideCollapsedSections: { + ...state.shortcutGuideCollapsedSections, + [sectionId]: !state.shortcutGuideCollapsedSections[sectionId], + }, + })), + setDrawAction: (mode) => + set((state) => ({ ...state, drawAction: mode })), + setSelectedObjectEditTarget: (target) => + set((state) => ({ ...state, selectedObjectEditTarget: target })), + setCopyDataIds: (ids) => + set((state) => ({ ...state, copyDataIds: ids })), + }), + { + name: "keyboard-store", + partialize: (state) => ({ + shortcutGuidePosition: state.shortcutGuidePosition, + shortcutGuideCollapsedSections: state.shortcutGuideCollapsedSections, + }), + } + ) +) From 012257bb60191fa9879dfac732db4dc12abf896c Mon Sep 17 00:00:00 2001 From: zhangheng Date: Mon, 20 Apr 2026 09:45:21 +0800 Subject: [PATCH 4/5] style(code): format --- .../label/components/PaperContainer.tsx | 16 +++++++++++----- components/label/useKeyBoardStore.tsx | 6 ++---- components/label/usePaperStore.ts | 19 ++++++++----------- components/label/utils/objectVisibility.ts | 5 ++++- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx index c6856b3..247c7b1 100644 --- a/components/label/components/PaperContainer.tsx +++ b/components/label/components/PaperContainer.tsx @@ -499,9 +499,7 @@ const PaperContainer = ( showTags, showTagsConfigs, } = useTopToolsStore() - const showShortcutGuide = useKeyboardStore( - (state) => state.showShortcutGuide - ) + const showShortcutGuide = useKeyboardStore((state) => state.showShortcutGuide) const shortcutGuidePosition = useKeyboardStore( (state) => state.shortcutGuidePosition ) @@ -4311,13 +4309,21 @@ const PaperContainer = ( border: "1px solid light-dark(rgba(34,139,230,0.12), rgba(114,192,252,0.14))", }}> - + 快捷键说明 - 默认靠左显示,可拖动;按 ? 快速开关 + 默认靠左显示,可拖动;按{" "} + + ? + {" "} + 快速开关 ()( [sectionId]: !state.shortcutGuideCollapsedSections[sectionId], }, })), - setDrawAction: (mode) => - set((state) => ({ ...state, drawAction: mode })), + setDrawAction: (mode) => set((state) => ({ ...state, drawAction: mode })), setSelectedObjectEditTarget: (target) => set((state) => ({ ...state, selectedObjectEditTarget: target })), - setCopyDataIds: (ids) => - set((state) => ({ ...state, copyDataIds: ids })), + setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })), }), { name: "keyboard-store", diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index b158c07..e562da9 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -902,9 +902,7 @@ const isPaperObjectType = (type: unknown) => typeof type === "string" && PAPER_OBJECT_TYPES.includes(type as (typeof PAPER_OBJECT_TYPES)[number]) -const syncPaperObjectStrokeScaling = ( - item: paper.Item | null | undefined -) => { +const syncPaperObjectStrokeScaling = (item: paper.Item | null | undefined) => { if (!(item instanceof paper.Path || item instanceof paper.CompoundPath)) { return } @@ -947,9 +945,13 @@ export const ensureEditablePaperPathSelected = (objectId: number) => { if (!objectPaths.length) return null - const editablePaths = objectPaths.filter((item) => !item.data?.isHollowPolygon) + const editablePaths = objectPaths.filter( + (item) => !item.data?.isHollowPolygon + ) const activePath = - editablePaths.find((item) => item.data?.selected) || editablePaths[0] || null + editablePaths.find((item) => item.data?.selected) || + editablePaths[0] || + null objectPaths.forEach((item) => { item.data.selected = item === activePath @@ -6721,12 +6723,7 @@ export const usePaperStore = create((set) => ({ } return circle }, - getBuffPath: ( - item: paper.Segment, - index: number, - id: number, - color: any - ) => { + getBuffPath: (item: paper.Segment, index: number, id: number, color: any) => { if (!item.next) return null const hitStrokeColor = clonePaperColor(color) || new paper.Color("#fff") hitStrokeColor.alpha = 0.001 diff --git a/components/label/utils/objectVisibility.ts b/components/label/utils/objectVisibility.ts index 9b43cf1..7b025c5 100644 --- a/components/label/utils/objectVisibility.ts +++ b/components/label/utils/objectVisibility.ts @@ -1,7 +1,10 @@ import paper from "paper" import { useLabelStore, useObjectStore } from "../store" import { useOtherToolsStore } from "../useOtherToolsStore" -import { ensureEditablePaperPathSelected, usePaperStore } from "../usePaperStore" +import { + ensureEditablePaperPathSelected, + usePaperStore, +} from "../usePaperStore" import { usePaperSupportStore } from "../usePaperSupportStore" import { useTopToolsStore } from "../useTopToolsStore" From e37f62605c2a066e4b9abfdb08f9a96f8fa8624b Mon Sep 17 00:00:00 2001 From: zhangheng Date: Mon, 20 Apr 2026 10:22:38 +0800 Subject: [PATCH 5/5] docs(doc): delete --- 5168f53_polygon_brush_point_edit_fix.md | 91 ------------------------- 1 file changed, 91 deletions(-) delete mode 100644 5168f53_polygon_brush_point_edit_fix.md diff --git a/5168f53_polygon_brush_point_edit_fix.md b/5168f53_polygon_brush_point_edit_fix.md deleted file mode 100644 index c8fdf90..0000000 --- a/5168f53_polygon_brush_point_edit_fix.md +++ /dev/null @@ -1,91 +0,0 @@ -# 5168f53 回归分析与修复记录 - -## 问题描述 -- 提交:`5168f539e0dbf84d6a1625d10ef98149fb21ffe6` -- 现象:`polygon` / `brush` / `point` 类型对象在被选中后,无法通过鼠标悬浮边触发边高亮,也无法通过双击边新增点。 - -## usePaperStore 关键链路梳理 -1. 选中对象 - - 入口主要在 `components/label/usePaperStore.ts` 的 `initPanTool -> panTool.onMouseDown` - - 通过 `resolvePanHitResult()` 命中对象、辅助边(`pathBuff`)或辅助点(`pathCircle`) - - 选中后会调用: - - `usePaperSupportStore.handleBufferPaths()` 生成可 hover / 可双击的辅助边 - - `usePaperSupportStore.handleCircles()` 生成可拖拽节点 - - `usePaperSupportStore.handleMask()` / `handleText()` 生成刷子/点类型附加辅助层 - -2. 悬浮边高亮 - - `usePaperSupportStore.handleBufferPaths()` 会遍历 path 的 segment,调用 `usePaperStore.getBuffPath()` 为每条边创建 `pathBuff` - - `getBuffPath()` 中给 `bufferPath` 绑定 `onMouseEnter / onMouseLeave` - - 悬浮时通过额外创建 `pathBuffHover` 白色描边来实现高亮 - - 同时把当前 hover 信息记入 `hoveredBufferPathInfo` - -3. 双击边新增点 - - 仍然在 `panTool.onMouseDown` - - 双击判定由 `isPaperDoubleClick()` 完成 - - 命中 `pathBuff` 或存在 `hoveredBufferPathInfo` 时,会走 `stroke` 分支或 `hoveredBufferInfo` 分支 - - 最终通过 `path.insert(hitIndex + 1, point)` 新增节点 - -4. 右侧/外部选择对象后的恢复链路 - - `components/label/utils/objectVisibility.ts` 的 `syncSelectedObjectsVisualState()` 负责根据 `selectedPath` 重新恢复画布上的选中态 - - 该链路也必须正确恢复: - - 选中的实际可编辑 path - - 辅助边 `pathBuff` - - 辅助点 `pathCircle` - - 否则对象虽“被选中”,但无法进入边 hover / 双击增点链路 - -## 回归根因 -### 根因 1:辅助边被改成完全透明,导致命中/hover 链路失效 -- 在 `getBuffPath()` 中,`pathBuff.strokeColor` 被改成了完全透明的颜色 -- 这会导致 `pathBuff` 在某些情况下不再稳定触发 hover / hit 行为 -- 后果: - - `hoveredBufferPathInfo` 无法建立 - - 边高亮不出现 - - 双击新增点依赖的 `pathBuff` 命中链路失效 - -### 根因 2:选中态恢复时,没有稳定选中“真正可编辑的 path” -- `polygon` / `brush` / `point` 可能存在 `CompoundPath + child Path` 的结构 -- 之前恢复选中态时,更多是“对象被选中”,但没有保证“某个可编辑 child path 被明确选中并生成辅助边/辅助点” -- 后果: - - 外部选中/恢复选中后,缺少 `pathBuff` - - 即使对象看起来是选中状态,也无法 hover 边、双击增点 - -## 修复方案 -### 已做 -- [x] 在 `components/label/usePaperStore.ts` 中新增 `ensureEditablePaperPathSelected()` - - 统一从同一对象 id 下挑出一个真实可编辑的 `paper.Path` - - 排除 `isHollowPolygon` - - 保证只有该可编辑 path 维持 `selected = true` - -- [x] 修复 `applyPaperSelectedPath()` - - 对 `polygon` / `brush` / `point` 不再只依赖当前 item 是否刚好是 `paper.Path` - - 改为统一拿到可编辑 path 后恢复: - - `handleBufferPaths` - - `handleCircles` - - `handleMask` - - `handleText` - -- [x] 修复 `components/label/utils/objectVisibility.ts` 中的 `applySelectedItemVisual()` - - 保证右侧面板/隐藏恢复/重绘恢复时,同样能恢复到真实可编辑 path 的辅助编辑态 - -- [x] 修复 `getBuffPath()` 的命中可用性 - - 不再使用 alpha 为 `0` 的完全透明描边 - - 改为极低透明度(`alpha = 0.001`)以保留 hover/hit 能力,同时视觉上保持不可见 - -- [x] 完成静态校验 - - 执行 `pnpm exec tsc --noEmit` - - 当前补丁未引入新的 TypeScript 类型错误 - -## 待验证 -- [ ] 直接点击已存在 `polygon` 对象后,悬浮边是否出现高亮 -- [ ] 直接点击已存在 `polygon` 对象后,双击边是否成功新增点 -- [ ] 直接点击已存在 `brush` 对象后,悬浮边是否出现高亮 -- [ ] 直接点击已存在 `brush` 对象后,双击边是否成功新增点 -- [ ] 直接点击已存在 `point` 对象后,悬浮边是否出现高亮 -- [ ] 直接点击已存在 `point` 对象后,双击边是否成功新增点 -- [ ] 从右侧对象列表切换选中后,上述三类对象是否仍可编辑 -- [ ] 多区域/CompoundPath 对象时,是否总能命中当前可编辑子路径 - -## 涉及文件 -- `components/label/usePaperStore.ts` -- `components/label/utils/objectVisibility.ts` -- `5168f53_polygon_brush_point_edit_fix.md`