diff --git a/web/package.json b/web/package.json index 4e2e5804b..d5739c34a 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,7 @@ "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", - "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", + "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-image-batch-retry.test.ts test/canvas-leafer-viewport.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/canvas/canvas-resource-mention-textarea.tsx b/web/src/components/canvas/canvas-resource-mention-textarea.tsx index ab8b20612..82c40e970 100644 --- a/web/src/components/canvas/canvas-resource-mention-textarea.tsx +++ b/web/src/components/canvas/canvas-resource-mention-textarea.tsx @@ -409,17 +409,17 @@ function createInlineMentionChip(reference: CanvasResourceReference, token: stri const chip = document.createElement("span"); chip.contentEditable = "false"; chip.dataset.mentionToken = token; - chip.className = "mx-[0.06em] inline-flex h-[1.68em] translate-y-[0.08em] select-none items-center gap-[0.18em] rounded-[0.38em] bg-black/[0.06] px-[0.22em] text-[0.92em] font-medium leading-none text-current align-baseline dark:bg-white/[0.1]"; + chip.className = "canvas-resource-inline-mention"; const at = document.createElement("span"); - at.className = "shrink-0 opacity-90"; + at.className = "canvas-resource-inline-at"; at.textContent = "@"; chip.appendChild(at); chip.appendChild(createInlinePreview(reference)); const label = document.createElement("span"); - label.className = "shrink-0"; + label.className = "canvas-resource-inline-label"; label.textContent = reference.label; chip.appendChild(label); @@ -429,7 +429,7 @@ function createInlineMentionChip(reference: CanvasResourceReference, token: stri function createInlinePreview(reference: CanvasResourceReference) { if ((reference.kind === "image" || reference.kind === "video" || reference.kind === "character") && reference.previewUrl) { const media = document.createElement(reference.kind === "video" ? "video" : "img"); - media.className = `size-[1.18em] shrink-0 rounded-[0.24em] ${reference.kind === "video" ? "bg-black object-cover" : reference.kind === "character" ? "bg-black/5 object-contain" : "object-cover"}`; + media.className = `canvas-resource-inline-preview is-${reference.kind}`; media.setAttribute("src", reference.previewUrl); media.setAttribute("alt", ""); if (media instanceof HTMLVideoElement) { @@ -439,7 +439,7 @@ function createInlinePreview(reference: CanvasResourceReference) { return media; } const fallback = document.createElement("span"); - fallback.className = "grid size-[1.18em] shrink-0 place-items-center rounded-[0.24em] bg-current/10"; + fallback.className = "canvas-resource-inline-preview is-fallback"; fallback.textContent = reference.sourceType === CanvasNodeType.Drawing ? "✎" : reference.kind === "audio" ? "♪" : reference.kind === "video" ? "▶" : reference.kind === "image" ? "□" : reference.kind === "skill" ? "✦" : ""; return fallback; } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 57f31a803..1015f2be6 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -748,6 +748,11 @@ --canvas-mention-menu-max-height: 320px; --canvas-mention-search-height: 40px; --canvas-mention-item-height: 34px; + --canvas-mention-chip-height: 1.68em; + --canvas-mention-chip-preview-size: 1.18em; + --canvas-mention-chip-gap: 0.18em; + --canvas-mention-chip-padding-inline: 0.22em; + --canvas-mention-chip-offset-y: 0.08em; /* Modal 组件(对话框,对应 #98 面板层级)*/ --modal-radius: var(--r-lg); @@ -7023,6 +7028,65 @@ outline: none; } + .canvas-resource-inline-mention { + display: inline-flex; + min-width: 0; + max-width: 100%; + height: var(--canvas-mention-chip-height); + align-items: center; + gap: var(--canvas-mention-chip-gap); + margin-inline: 0.06em; + overflow: hidden; + border-radius: var(--r-xs); + background: var(--canvas-composer-control-surface); + padding-inline: var(--canvas-mention-chip-padding-inline); + color: inherit; + font-size: 0.92em; + font-weight: 500; + line-height: 1; + vertical-align: baseline; + user-select: none; + transform: translateY(var(--canvas-mention-chip-offset-y)); + } + + .canvas-resource-inline-at, + .canvas-resource-inline-label { + flex: 0 0 auto; + } + + .canvas-resource-inline-at { + opacity: 0.9; + } + + .canvas-resource-inline-preview { + display: block; + width: var(--canvas-mention-chip-preview-size); + min-width: var(--canvas-mention-chip-preview-size); + max-width: var(--canvas-mention-chip-preview-size); + height: var(--canvas-mention-chip-preview-size); + min-height: var(--canvas-mention-chip-preview-size); + max-height: var(--canvas-mention-chip-preview-size); + flex: 0 0 var(--canvas-mention-chip-preview-size); + overflow: hidden; + border-radius: var(--r-xs); + object-fit: cover; + } + + .canvas-resource-inline-preview.is-video { + background: var(--color-neutral-950); + } + + .canvas-resource-inline-preview.is-character { + background: color-mix(in srgb, var(--foreground) 5%, transparent); + object-fit: contain; + } + + .canvas-resource-inline-preview.is-fallback { + display: grid; + place-items: center; + background: color-mix(in srgb, currentColor 10%, transparent); + } + .canvas-resource-mention-menu { display: flex; max-height: var(--canvas-mention-menu-max-height); diff --git a/web/test/canvas-resource-mention-editor.test.ts b/web/test/canvas-resource-mention-editor.test.ts new file mode 100644 index 000000000..4ac21bc7c --- /dev/null +++ b/web/test/canvas-resource-mention-editor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +function source(path: string) { + return readFileSync(resolve(import.meta.dir, path), "utf8"); +} + +describe("canvas resource mention editor", () => { + test("uses stable component classes for inline media references", () => { + const component = source("../src/components/canvas/canvas-resource-mention-textarea.tsx"); + + expect(component).toContain('chip.className = "canvas-resource-inline-mention"'); + expect(component).toContain("canvas-resource-inline-preview is-${reference.kind}"); + expect(component).not.toContain("size-[1.18em]"); + }); + + test("clamps native media dimensions so video previews cannot cover prompt text", () => { + const css = source("../src/styles/globals.css"); + const previewRule = css.match(/\.canvas-resource-inline-preview \{[^}]+}/)?.[0] || ""; + + expect(previewRule).toContain("width: var(--canvas-mention-chip-preview-size)"); + expect(previewRule).toContain("min-width: var(--canvas-mention-chip-preview-size)"); + expect(previewRule).toContain("max-width: var(--canvas-mention-chip-preview-size)"); + expect(previewRule).toContain("height: var(--canvas-mention-chip-preview-size)"); + expect(previewRule).toContain("flex: 0 0 var(--canvas-mention-chip-preview-size)"); + expect(previewRule).toContain("object-fit: cover"); + }); +});