From b3161d2ebc684b3c5152720d1fa1a88d7aaf7032 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 5 Aug 2026 00:02:23 +0800 Subject: [PATCH] fix(canvas): restore skill search and drag ordering --- app/api/canvas-skill-tags/route.ts | 18 +- components/canvas/CanvasChatPanel.tsx | 196 ++++++++++---------- lib/canvas-skill-tag-order.ts | 35 ++++ playwright.helpers.config.ts | 1 + tests/routes/canvas-skill-tag-order.test.ts | 38 ++++ 5 files changed, 180 insertions(+), 108 deletions(-) create mode 100644 lib/canvas-skill-tag-order.ts create mode 100644 tests/routes/canvas-skill-tag-order.test.ts diff --git a/app/api/canvas-skill-tags/route.ts b/app/api/canvas-skill-tags/route.ts index 22c9ec3c0..1b18b6853 100644 --- a/app/api/canvas-skill-tags/route.ts +++ b/app/api/canvas-skill-tags/route.ts @@ -7,9 +7,9 @@ import { CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX, CANVAS_SKILL_TAGS_CONFIG_VERSION, CANVAS_SKILL_TAGS_SETTING_KEY, - type CanvasSkillTag, normalizeCanvasSkillTagSettings, } from "@/lib/canvas-skill-tags"; +import { applyCanvasSkillTagOrder } from "@/lib/canvas-skill-tag-order"; function getUserSkillTagOrderKey(userId: string) { return `${CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX}${userId}`; @@ -32,18 +32,6 @@ function normalizeSkillTagOrder(value: unknown) { }); } -function applySkillTagOrder(tags: CanvasSkillTag[], order: string[]) { - if (tags.length < 2 || order.length === 0) return tags; - - const tagById = new Map(tags.map((tag) => [tag.id, tag])); - const ordered = order - .map((id) => tagById.get(id)) - .filter((tag): tag is CanvasSkillTag => Boolean(tag)); - const orderedIds = new Set(ordered.map((tag) => tag.id)); - const remaining = tags.filter((tag) => !orderedIds.has(tag.id)); - return [...ordered, ...remaining]; -} - export async function GET() { try { const session = await auth().catch(() => null); @@ -71,7 +59,7 @@ export async function GET() { const userOrder = normalizeSkillTagOrder(userOrderSetting?.value); return NextResponse.json({ ...settings, - tags: applySkillTagOrder(settings.tags, userOrder), + tags: applyCanvasSkillTagOrder(settings.tags, userOrder), userOrder, }); } catch (error) { @@ -133,7 +121,7 @@ export async function PATCH(request: NextRequest) { return NextResponse.json({ success: true, userOrder: tagIds, - tags: applySkillTagOrder(settings.tags, tagIds), + tags: applyCanvasSkillTagOrder(settings.tags, tagIds), }); } catch (error) { console.error("[CanvasSkillTags API] Save order error:", error); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 24d302213..1c3e95f25 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -2,7 +2,7 @@ import { useChat } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; -import { useEffect, useRef, useState, useCallback, useMemo, type ClipboardEvent as ReactClipboardEvent, type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react"; +import { useEffect, useRef, useState, useCallback, useMemo, type ClipboardEvent as ReactClipboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react"; import { useSession } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { createPortal } from "react-dom"; @@ -111,6 +111,10 @@ import { normalizeCanvasSkillTags, type CanvasSkillTag, } from "@/lib/canvas-skill-tags"; +import { + applyCanvasSkillTagOrder, + reorderCanvasSkillTags, +} from "@/lib/canvas-skill-tag-order"; import { isCanvasImageReadyForAi, isCanvasImageUploadPending, @@ -494,34 +498,15 @@ function buildClawdSkillTagPrompt(tag: Pick | ].join("\n"); } -function reorderCanvasSkillTags( - tags: CanvasSkillTag[], - draggedId: string, - targetId: string -) { - if (!draggedId || !targetId || draggedId === targetId) return tags; - const currentIndex = tags.findIndex((tag) => tag.id === draggedId); - const targetIndex = tags.findIndex((tag) => tag.id === targetId); - if (currentIndex < 0 || targetIndex < 0) return tags; +const CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX = 6; - const nextTags = [...tags]; - const [draggedTag] = nextTags.splice(currentIndex, 1); - if (!draggedTag) return tags; - const nextTargetIndex = nextTags.findIndex((tag) => tag.id === targetId); - nextTags.splice(nextTargetIndex < 0 ? targetIndex : nextTargetIndex, 0, draggedTag); - return nextTags; -} - -function applyCanvasSkillTagOrder(tags: CanvasSkillTag[], order: string[]) { - if (tags.length < 2 || order.length === 0) return tags; - - const tagById = new Map(tags.map((tag) => [tag.id, tag])); - const orderedTags = order - .map((id) => tagById.get(id)) - .filter((tag): tag is CanvasSkillTag => Boolean(tag)); - const orderedIds = new Set(orderedTags.map((tag) => tag.id)); - return [...orderedTags, ...tags.filter((tag) => !orderedIds.has(tag.id))]; -} +type CanvasSkillTagPointerDragState = { + pointerId: number; + tagId: string; + startX: number; + startY: number; + moved: boolean; +}; type ClawdComposerSubmissionSnapshot = { chatId: string; @@ -2757,7 +2742,9 @@ export function CanvasChatPanel({ null ); const pendingSelectedClawdSkillTagRef = useRef(null); - const draggingSkillTagIdRef = useRef(null); + const canvasSkillTagPointerDragRef = useRef( + null + ); const latestCanvasSkillTagsRef = useRef([]); const pendingCanvasSkillTagOrderSaveRef = useRef(null); const suppressSkillTagClickRef = useRef(false); @@ -5176,6 +5163,12 @@ export function CanvasChatPanel({ }, body: JSON.stringify({ tagIds: userOrder }), }); + if (response.status === 401) { + setCanvasSkillTagOrderOverride(null); + requestCanvasLogin(onRequireLogin, OPENCLAW_AUTH_REQUIRED_MESSAGE); + void mutate("/api/canvas-skill-tags"); + return; + } if (!response.ok) { throw new Error("Failed to save skill tag order"); } @@ -5188,7 +5181,7 @@ export function CanvasChatPanel({ void mutate("/api/canvas-skill-tags"); } }, - [baseCanvasSkillTags, canvasSkillTagsData, mutate] + [baseCanvasSkillTags, canvasSkillTagsData, mutate, onRequireLogin] ); const selectedSkillTag = useMemo( () => canvasSkillTags.find((tag) => tag.id === selectedSkillTagId) || null, @@ -6528,23 +6521,9 @@ export function CanvasChatPanel({ ] ); - const handleCanvasSkillTagDragStart = useCallback( - (event: ReactDragEvent, tagId: string) => { - draggingSkillTagIdRef.current = tagId; - pendingCanvasSkillTagOrderSaveRef.current = null; - setDraggingSkillTagId(tagId); - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", tagId); - }, - [] - ); - - const handleCanvasSkillTagDragEnter = useCallback( - (event: ReactDragEvent, targetId: string) => { - const draggedId = draggingSkillTagIdRef.current; + const updateCanvasSkillTagPointerOrder = useCallback( + (draggedId: string, targetId: string) => { if (!draggedId || draggedId === targetId) return; - - event.preventDefault(); const currentTags = latestCanvasSkillTagsRef.current; const nextTags = reorderCanvasSkillTags(currentTags, draggedId, targetId); if (nextTags === currentTags) return; @@ -6557,53 +6536,88 @@ export function CanvasChatPanel({ [] ); - const handleCanvasSkillTagDragOver = useCallback( - (event: ReactDragEvent, targetId: string) => { - const draggedId = draggingSkillTagIdRef.current; - if (!draggedId || draggedId === targetId) return; - event.preventDefault(); - event.dataTransfer.dropEffect = "move"; - }, - [] - ); - - const handleCanvasSkillTagDrop = useCallback( - (event: ReactDragEvent, targetId: string) => { - event.preventDefault(); - const draggedId = - event.dataTransfer.getData("text/plain") || draggingSkillTagIdRef.current; - if (draggedId && draggedId !== targetId && !pendingCanvasSkillTagOrderSaveRef.current) { - const nextTags = reorderCanvasSkillTags( - latestCanvasSkillTagsRef.current, - draggedId, - targetId - ); - if (nextTags !== latestCanvasSkillTagsRef.current) { - const nextOrder = nextTags.map((tag) => tag.id); - pendingCanvasSkillTagOrderSaveRef.current = nextOrder; - latestCanvasSkillTagsRef.current = nextTags; - setCanvasSkillTagOrderOverride(nextOrder); - } + const handleCanvasSkillTagPointerDown = useCallback( + (event: ReactPointerEvent, tagId: string) => { + if ( + isCanvasSkillTagSearching || + canvasSkillTags.length < 2 || + event.button !== 0 + ) { + return; + } + if (sessionStatus === "loading") { + return; + } + if (!session?.user?.id) { + requestCanvasLogin(onRequireLogin, OPENCLAW_AUTH_REQUIRED_MESSAGE); + return; } - suppressSkillTagClickRef.current = true; - window.setTimeout(() => { - suppressSkillTagClickRef.current = false; - }, 0); + canvasSkillTagPointerDragRef.current = { + pointerId: event.pointerId, + tagId, + startX: event.clientX, + startY: event.clientY, + moved: false, + }; + pendingCanvasSkillTagOrderSaveRef.current = null; + event.currentTarget.setPointerCapture(event.pointerId); }, - [] + [ + canvasSkillTags.length, + isCanvasSkillTagSearching, + onRequireLogin, + session?.user?.id, + sessionStatus, + ] ); - const handleCanvasSkillTagDragEnd = useCallback(() => { + const handleCanvasSkillTagPointerMove = useCallback( + (event: ReactPointerEvent) => { + const dragState = canvasSkillTagPointerDragRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + + if (!dragState.moved) { + const distance = Math.hypot( + event.clientX - dragState.startX, + event.clientY - dragState.startY + ); + if (distance < CANVAS_SKILL_TAG_DRAG_THRESHOLD_PX) return; + + dragState.moved = true; + setDraggingSkillTagId(dragState.tagId); + } + + event.preventDefault(); + const target = document + .elementFromPoint(event.clientX, event.clientY) + ?.closest("[data-canvas-skill-tag-id]"); + const targetId = String(target?.dataset.canvasSkillTagId || "").trim(); + updateCanvasSkillTagPointerOrder(dragState.tagId, targetId); + }, + [updateCanvasSkillTagPointerOrder] + ); + + const handleCanvasSkillTagPointerEnd = useCallback(( + event: ReactPointerEvent + ) => { + const dragState = canvasSkillTagPointerDragRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + const pendingOrder = pendingCanvasSkillTagOrderSaveRef.current; pendingCanvasSkillTagOrderSaveRef.current = null; - draggingSkillTagIdRef.current = null; + canvasSkillTagPointerDragRef.current = null; setDraggingSkillTagId(null); - if (pendingOrder?.length) { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (dragState.moved) { suppressSkillTagClickRef.current = true; window.setTimeout(() => { suppressSkillTagClickRef.current = false; }, 0); + } + if (dragState.moved && pendingOrder?.length) { void persistCanvasSkillTagOrder(pendingOrder); } }, [persistCanvasSkillTagOrder]); @@ -14955,24 +14969,19 @@ export function CanvasChatPanel({