fix(canvas): restore skill search and drag ordering
This commit is contained in:
parent
39e8107f23
commit
b3161d2ebc
@ -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);
|
||||
|
||||
@ -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<CanvasSkillTag, "name" | "prompt"> |
|
||||
].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<CanvasSkillTag | null>(null);
|
||||
const draggingSkillTagIdRef = useRef<string | null>(null);
|
||||
const canvasSkillTagPointerDragRef = useRef<CanvasSkillTagPointerDragState | null>(
|
||||
null
|
||||
);
|
||||
const latestCanvasSkillTagsRef = useRef<CanvasSkillTag[]>([]);
|
||||
const pendingCanvasSkillTagOrderSaveRef = useRef<string[] | null>(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<HTMLButtonElement>, 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<HTMLButtonElement>, 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<HTMLButtonElement>, targetId: string) => {
|
||||
const draggedId = draggingSkillTagIdRef.current;
|
||||
if (!draggedId || draggedId === targetId) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCanvasSkillTagDrop = useCallback(
|
||||
(event: ReactDragEvent<HTMLButtonElement>, 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<HTMLButtonElement>, 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<HTMLButtonElement>) => {
|
||||
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<HTMLElement>("[data-canvas-skill-tag-id]");
|
||||
const targetId = String(target?.dataset.canvasSkillTagId || "").trim();
|
||||
updateCanvasSkillTagPointerOrder(dragState.tagId, targetId);
|
||||
},
|
||||
[updateCanvasSkillTagPointerOrder]
|
||||
);
|
||||
|
||||
const handleCanvasSkillTagPointerEnd = useCallback((
|
||||
event: ReactPointerEvent<HTMLButtonElement>
|
||||
) => {
|
||||
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({
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
draggable={!isCanvasSkillTagSearching && canvasSkillTags.length > 1}
|
||||
onDragStart={(event) =>
|
||||
handleCanvasSkillTagDragStart(event, tag.id)
|
||||
data-canvas-skill-tag-id={tag.id}
|
||||
onPointerDown={(event) =>
|
||||
handleCanvasSkillTagPointerDown(event, tag.id)
|
||||
}
|
||||
onDragEnter={(event) =>
|
||||
handleCanvasSkillTagDragEnter(event, tag.id)
|
||||
}
|
||||
onDragOver={(event) =>
|
||||
handleCanvasSkillTagDragOver(event, tag.id)
|
||||
}
|
||||
onDrop={(event) => handleCanvasSkillTagDrop(event, tag.id)}
|
||||
onDragEnd={handleCanvasSkillTagDragEnd}
|
||||
onPointerMove={handleCanvasSkillTagPointerMove}
|
||||
onPointerUp={handleCanvasSkillTagPointerEnd}
|
||||
onPointerCancel={handleCanvasSkillTagPointerEnd}
|
||||
onClick={() => handleSelectCanvasSkillTag(tag)}
|
||||
className={cn(
|
||||
"inline-flex h-[34px] max-w-full items-center justify-center gap-1.5 rounded-full border px-3.5 text-[13px] font-medium leading-none transition-colors",
|
||||
isCanvasSkillTagSearching
|
||||
? "cursor-pointer"
|
||||
: "cursor-grab active:cursor-grabbing",
|
||||
: "touch-none select-none cursor-grab active:cursor-grabbing",
|
||||
isActive
|
||||
? "border-[#d8dce4] bg-[#f7f8fa] text-[#1f2328] dark:border-blue-400/40 dark:bg-blue-500/15 dark:text-blue-200"
|
||||
: "border-[#e5e7eb] bg-white text-[#1f2328] hover:border-[#d6dae3] hover:bg-[#fafbfc] dark:border-white/10 dark:bg-white/5 dark:text-zinc-200 dark:hover:border-white/20 dark:hover:bg-white/10",
|
||||
@ -14988,6 +14997,7 @@ export function CanvasChatPanel({
|
||||
<img
|
||||
src={buildPreviewImageUrl(tag.iconUrl, 40)}
|
||||
alt={tag.name}
|
||||
draggable={false}
|
||||
className="h-4 w-4 rounded-sm object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
35
lib/canvas-skill-tag-order.ts
Normal file
35
lib/canvas-skill-tag-order.ts
Normal file
@ -0,0 +1,35 @@
|
||||
type OrderedItem = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function reorderCanvasSkillTags<T extends OrderedItem>(
|
||||
tags: T[],
|
||||
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 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;
|
||||
}
|
||||
|
||||
export function applyCanvasSkillTagOrder<T extends OrderedItem>(
|
||||
tags: T[],
|
||||
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 T => Boolean(tag));
|
||||
const orderedIds = new Set(orderedTags.map((tag) => tag.id));
|
||||
return [...orderedTags, ...tags.filter((tag) => !orderedIds.has(tag.id))];
|
||||
}
|
||||
@ -8,6 +8,7 @@ export default defineConfig({
|
||||
/routes\/canvas-grid-snap\.test\.ts/,
|
||||
/routes\/canvas-project-title\.test\.ts/,
|
||||
/routes\/canvas-shortcut-help\.test\.ts/,
|
||||
/routes\/canvas-skill-tag-order\.test\.ts/,
|
||||
/routes\/canvas-grouping\.test\.ts/,
|
||||
/routes\/canvas-arrange\.test\.ts/,
|
||||
/routes\/canvas-asset-manager\.test\.ts/,
|
||||
|
||||
38
tests/routes/canvas-skill-tag-order.test.ts
Normal file
38
tests/routes/canvas-skill-tag-order.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
applyCanvasSkillTagOrder,
|
||||
reorderCanvasSkillTags,
|
||||
} from "@/lib/canvas-skill-tag-order";
|
||||
|
||||
const tags = [
|
||||
{ id: "video", name: "视频理解" },
|
||||
{ id: "image", name: "以图搜图" },
|
||||
{ id: "market", name: "市场大盘分析" },
|
||||
{ id: "stock", name: "库存查询" },
|
||||
];
|
||||
|
||||
test.describe("canvas skill tag ordering", () => {
|
||||
test("moves a later skill before an earlier target", () => {
|
||||
expect(
|
||||
reorderCanvasSkillTags(tags, "stock", "image").map((tag) => tag.id)
|
||||
).toEqual(["video", "stock", "image", "market"]);
|
||||
});
|
||||
|
||||
test("moves an earlier skill before a later target", () => {
|
||||
expect(
|
||||
reorderCanvasSkillTags(tags, "video", "stock").map((tag) => tag.id)
|
||||
).toEqual(["image", "market", "video", "stock"]);
|
||||
});
|
||||
|
||||
test("keeps newly configured skills after a saved partial order", () => {
|
||||
expect(
|
||||
applyCanvasSkillTagOrder(tags, ["market", "video"]).map((tag) => tag.id)
|
||||
).toEqual(["market", "video", "image", "stock"]);
|
||||
});
|
||||
|
||||
test("ignores unknown ids in a saved order", () => {
|
||||
expect(
|
||||
applyCanvasSkillTagOrder(tags, ["missing", "image"]).map((tag) => tag.id)
|
||||
).toEqual(["image", "video", "market", "stock"]);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user