Compare commits

...

2 Commits

Author SHA1 Message Date
leiyuee 1297a0f514 Merge branch 'master' into version/1.2.12 4 days ago
leiyuee e71aaee29a fix:详情弹窗的功能弹窗背景颜色优化 5 days ago
  1. BIN
      src/assets/images/index/popi_art_workspace_preview.png
  2. 7
      src/components/AssetAudioPlayer/index.tsx
  3. 12
      src/dialog/asset-details/components/AssetExpandImageModal/index.scss
  4. 55
      src/dialog/asset-details/components/AssetExpandImageModal/index.tsx
  5. 1881
      src/dialog/asset-details/index.scss
  6. 158
      src/dialog/gateway-apikey/index.tsx
  7. 32
      src/layouts/globalPromptPanel/ComposerMediaSortableList.tsx
  8. 6
      src/layouts/globalPromptPanel/composerComponents/ComposerFooter.tsx
  9. 33
      src/layouts/globalPromptPanel/composerComponents/ComposerModelTags.tsx
  10. 5
      src/layouts/globalPromptPanel/composerSections/HeadTailUploadPromptSection.tsx
  11. 43
      src/layouts/globalPromptPanel/globalPromptPanel.scss
  12. 2
      src/layouts/globalPromptPanel/globalPromptPanel.tsx
  13. 8
      src/layouts/globalPromptPanel/types.ts
  14. 47
      src/layouts/globalPromptPanel/utils.ts
  15. 93
      src/pages/CharactersCreate/index.scss
  16. 47
      src/pages/CharactersCreate/index.tsx
  17. 6
      src/pages/Voice/components/music/VoiceMusicPanel.tsx
  18. 26
      src/pages/Voice/components/speech/VoiceSpeechPanel.tsx
  19. 7
      src/utils/horizontalWheelScroll.ts

BIN
src/assets/images/index/popi_art_workspace_preview.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.4 MiB

7
src/components/AssetAudioPlayer/index.tsx

@ -18,6 +18,7 @@ export interface AssetAudioPlayerProps {
src: string;
resetKey?: string | number;
autoPlay?: boolean;
autoAdvanceOnEnded?: boolean;
className?: string;
onPlayingChange?: (playing: boolean) => void;
canGoPrevious?: boolean;
@ -27,7 +28,7 @@ export interface AssetAudioPlayerProps {
}
export const AssetAudioPlayer = forwardRef<AssetAudioPlayerHandle, AssetAudioPlayerProps>(function AssetAudioPlayer(
{ src, resetKey, autoPlay = false, className, onPlayingChange, canGoPrevious = false, canGoNext = false, onPrevious, onNext },
{ src, resetKey, autoPlay = false, autoAdvanceOnEnded = false, className, onPlayingChange, canGoPrevious = false, canGoNext = false, onPrevious, onNext },
forwardedRef,
) {
const { t } = useTranslation();
@ -68,7 +69,7 @@ export const AssetAudioPlayer = forwardRef<AssetAudioPlayerHandle, AssetAudioPla
const onEnded = () => {
setIsPlaying(false);
onPlayingChange?.(false);
if (canGoNext && onNext) {
if (autoAdvanceOnEnded && canGoNext && onNext) {
onNext();
}
};
@ -97,7 +98,7 @@ export const AssetAudioPlayer = forwardRef<AssetAudioPlayerHandle, AssetAudioPla
audio.removeEventListener("pause", onPause);
audio.pause();
};
}, [canGoNext, onNext, onPlayingChange, voiceSrc]);
}, [autoAdvanceOnEnded, canGoNext, onNext, onPlayingChange, voiceSrc]);
const togglePlay = useCallback(() => {
const audio = audioRef.current;

12
src/dialog/asset-details/components/AssetExpandImageModal/index.scss

@ -214,6 +214,12 @@
color: #333333;
}
.assetDetailsExpandImageModal__scaleButton:disabled {
color: #c8c8c8;
cursor: not-allowed;
opacity: 0.55;
}
.assetDetailsExpandImageModal__ratioBar {
height: 66px;
padding: 5px;
@ -247,6 +253,12 @@
color: #333333;
}
.assetDetailsExpandImageModal__ratioButton:disabled {
color: #c8c8c8;
cursor: not-allowed;
opacity: 0.55;
}
.assetDetailsExpandImageModal__ratioIcon {
width: 20px;
height: 20px;

55
src/dialog/asset-details/components/AssetExpandImageModal/index.tsx

@ -31,6 +31,9 @@ const EXPAND_IMAGE_PREVIEW_HEIGHT = 369;
const EXPAND_IMAGE_ORIGINAL_IMAGE_MAX_SIZE = { width: 140, height: 122 };
const EXPAND_IMAGE_ORIGINAL_FRAME_TOP = 66;
const EXPAND_IMAGE_FIXED_RATIO_MAX_SIZE = { width: 560, height: 315 };
const EXPAND_IMAGE_MAX_DIRECTION_RATIO = 0.4;
const EXPAND_IMAGE_MAX_AXIS_RATIO = EXPAND_IMAGE_MAX_DIRECTION_RATIO * 2;
const EXPAND_IMAGE_RATIO_EPSILON = 0.0001;
const EXPAND_IMAGE_SCALE_MULTIPLIERS: Record<ExpandImageScale, number> = {
"1.5x": 1.5,
"2x": 2,
@ -97,7 +100,7 @@ function clampNumber(value: number, min: number, max: number) {
function clampExpandImageInsetRatio(value: number) {
if (!Number.isFinite(value)) return 0;
return clampNumber(Math.round(value * 10000) / 10000, 0, 1);
return clampNumber(Math.round(value * 10000) / 10000, 0, EXPAND_IMAGE_MAX_DIRECTION_RATIO);
}
function fitExpandImageSize(aspectRatio: number, maxSize: ExpandImageSize): ExpandImageSize {
@ -117,11 +120,34 @@ function fitExpandImageSize(aspectRatio: number, maxSize: ExpandImageSize): Expa
};
}
function getExpandImageAxisPositionBounds(maxOffset: number, imageSide: number) {
function getExpandImageAxisExpandRatio(maxOffset: number, imageSide: number) {
if (maxOffset <= 0 || imageSide <= 0) {
return 0;
}
return maxOffset / imageSide;
}
function isExpandImageAxisWithinLimit(maxOffset: number, imageSide: number) {
return getExpandImageAxisExpandRatio(maxOffset, imageSide) <= EXPAND_IMAGE_MAX_AXIS_RATIO + EXPAND_IMAGE_RATIO_EPSILON;
}
function isExpandImageLayoutWithinLimit(layout: ExpandImagePreviewLayout) {
return isExpandImageAxisWithinLimit(layout.maxOffsetX, layout.imageSize.width)
&& isExpandImageAxisWithinLimit(layout.maxOffsetY, layout.imageSize.height);
}
function getExpandImageAxisPositionBounds(maxOffset: number, imageSide: number) {
const totalExpandRatio = getExpandImageAxisExpandRatio(maxOffset, imageSide);
if (totalExpandRatio <= 0) {
return { min: 0.5, max: 0.5 };
}
if (totalExpandRatio > EXPAND_IMAGE_MAX_AXIS_RATIO + EXPAND_IMAGE_RATIO_EPSILON) {
return { min: 0.5, max: 0.5 };
}
return { min: 0, max: 1 };
const maxPosition = Math.min(1, EXPAND_IMAGE_MAX_DIRECTION_RATIO / totalExpandRatio);
const minPosition = Math.max(0, 1 - maxPosition);
return { min: minPosition, max: maxPosition };
}
function clampExpandImagePositionForLayout(position: ExpandImagePosition, layout: ExpandImagePreviewLayout): ExpandImagePosition {
@ -268,6 +294,16 @@ function getExpandImagePreviewLayout(ratio: ExpandImageRatio, scale: ExpandImage
};
}
function isExpandImageScaleAvailable(scale: ExpandImageScale, imageNaturalSize: ExpandImageSize | null) {
return isExpandImageLayoutWithinLimit(getExpandImagePreviewLayout("original", scale, imageNaturalSize));
}
function isExpandImageRatioAvailable(ratio: ExpandImageRatio, imageNaturalSize: ExpandImageSize | null) {
if (ratio === "original") return true;
if (imageNaturalSize == null) return false;
return isExpandImageLayoutWithinLimit(getExpandImagePreviewLayout(ratio, "1.5x", imageNaturalSize));
}
export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", onClose }: AssetExpandImageModalProps) {
const { t } = useTranslation();
const setUser = useUserStore((s) => s.setUser);
@ -285,6 +321,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
const isOriginalRatio = ratio === "original";
const previewLayout = useMemo(() => getExpandImagePreviewLayout(ratio, scale, imageNaturalSize), [imageNaturalSize, ratio, scale]);
const selectedLayoutAvailable = isExpandImageLayoutWithinLimit(previewLayout);
const boundedImagePosition = useMemo(() => clampExpandImagePositionForLayout(imagePosition, previewLayout), [imagePosition, previewLayout]);
const positionedImageStyle = useMemo<CSSProperties>(() => ({
...previewLayout.imageStyle,
@ -312,6 +349,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
});
const createCost = costResult.cost;
const modelReady = !modelsLoading && activeModel != null && costPayload != null;
const canCreateExpandImage = modelReady && imageNaturalSize != null && selectedLayoutAvailable;
const scaleHintText = isOriginalRatio
? scale
: t("pages.assetDetails.expandImage.sizeHint", {
@ -327,12 +365,14 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
const handleScaleChange = (nextScale: ExpandImageScale) => {
if (nextScale === scale) return;
if (!isExpandImageScaleAvailable(nextScale, imageNaturalSize)) return;
resetImageDragState();
setScale(nextScale);
};
const handleRatioChange = (nextRatio: ExpandImageRatio) => {
if (nextRatio === ratio) return;
if (!isExpandImageRatioAvailable(nextRatio, imageNaturalSize)) return;
resetImageDragState();
setRatio(nextRatio);
};
@ -379,7 +419,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
}, [visible]);
const handleImagePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!canDragImage || creating) return;
if (!canDragImage || creating || !selectedLayoutAvailable) return;
event.preventDefault();
const clampedPosition = clampExpandImagePositionForLayout(imagePosition, previewLayout);
dragStateRef.current = {
@ -421,7 +461,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
};
const handleCreate = async () => {
if (createTaskInFlightRef.current || creating || !modelReady || imageNaturalSize == null) return;
if (createTaskInFlightRef.current || creating || !canCreateExpandImage) return;
const payload = buildExpandImageTaskPayload({
model: activeModel,
image: imageUrl,
@ -512,6 +552,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
role="radio"
aria-checked={scaleOption === scale}
aria-label={t("pages.assetDetails.expandImage.scaleOptionAria", { scale: scaleOption })}
disabled={!isExpandImageScaleAvailable(scaleOption, imageNaturalSize)}
onClick={() => {
handleScaleChange(scaleOption);
}}
@ -526,6 +567,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
<div className="assetDetailsExpandImageModal__ratioBar" role="radiogroup" aria-label={t("pages.assetDetails.expandImage.ratioAria")}>
{EXPAND_IMAGE_RATIO_OPTIONS.map((ratioOption) => {
const ratioLabel = ratioOption.value === "original" ? t("pages.assetDetails.expandImage.ratioOriginal") : ratioOption.value;
const ratioDisabled = !isExpandImageRatioAvailable(ratioOption.value, imageNaturalSize);
return (
<button
key={ratioOption.value}
@ -537,6 +579,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
role="radio"
aria-checked={ratioOption.value === ratio}
aria-label={t("pages.assetDetails.expandImage.ratioOptionAria", { ratio: ratioLabel })}
disabled={ratioDisabled}
onClick={() => {
handleRatioChange(ratioOption.value);
}}
@ -561,7 +604,7 @@ export function AssetExpandImageModal({ visible, imageUrl, originalRatio = "", o
<button
type="button"
className="assetDetailsExpandImageModal__createButton"
disabled={creating || !modelReady || imageNaturalSize == null}
disabled={creating || !canCreateExpandImage}
aria-busy={creating}
aria-label={t("pages.assetDetails.expandImage.createAria", { credits: createCost })}
onClick={() => { void handleCreate(); }}

1881
src/dialog/asset-details/index.scss

File diff suppressed because it is too large

158
src/dialog/gateway-apikey/index.tsx

@ -3,78 +3,78 @@ import { Message } from "@arco-design/web-react";
import { useTranslation } from "react-i18next";
import { getGatewayApikeyList, setGatewayApikeyStatus } from "@/api/api";
import { useCopyText } from "@/hooks/useCopyText";
import "./index.scss";
interface GatewayApikeyItem {
id: number;
code: string;
apikey: string;
status: number;
createTime?: string | number;
}
function formatDateTime(value?: string | number): string {
if (value == null) return "-";
const date = new Date(value);
import { useCopyText } from "@/hooks/useCopyText";
import "./index.scss";
interface GatewayApikeyItem {
id: number;
code: string;
apikey: string;
status: number;
createTime?: string | number;
}
function formatDateTime(value?: string | number): string {
if (value == null) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${String(y)}-${m}-${d} ${hh}:${mm}`;
}
function maskApikey(value: string): string {
if (!value) return "-";
if (value.length <= 10) return value;
return `${value.slice(0, 5)}...${value.slice(-4)}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object";
}
function getString(record: Record<string, unknown>, key: string): string {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
function getNumber(record: Record<string, unknown>, key: string): number | null {
const value = record[key];
const numericValue = typeof value === "number" ? value : typeof value === "string" ? Number(value) : null;
return numericValue != null && Number.isFinite(numericValue) ? numericValue : null;
}
function normalizeGatewayApikeyItem(value: unknown): GatewayApikeyItem | null {
if (!isRecord(value)) return null;
const id = getNumber(value, "id");
if (id == null) return null;
return {
id,
code: getString(value, "code"),
apikey: getString(value, "apikey"),
status: getNumber(value, "status") ?? 2,
createTime: getString(value, "createTime") || getString(value, "create_time") || undefined,
};
}
const d = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
return `${String(y)}-${m}-${d} ${hh}:${mm}`;
}
function maskApikey(value: string): string {
if (!value) return "-";
if (value.length <= 10) return value;
return `${value.slice(0, 5)}...${value.slice(-4)}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object";
}
function getString(record: Record<string, unknown>, key: string): string {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
function getNumber(record: Record<string, unknown>, key: string): number | null {
const value = record[key];
const numericValue = typeof value === "number" ? value : typeof value === "string" ? Number(value) : null;
return numericValue != null && Number.isFinite(numericValue) ? numericValue : null;
}
function normalizeGatewayApikeyItem(value: unknown): GatewayApikeyItem | null {
if (!isRecord(value)) return null;
const id = getNumber(value, "id");
if (id == null) return null;
return {
id,
code: getString(value, "code"),
apikey: getString(value, "apikey"),
status: getNumber(value, "status") ?? 2,
createTime: getString(value, "createTime") || getString(value, "create_time") || undefined,
};
}
export default function GatewayApikeyDialog() {
const { t } = useTranslation();
const copyText = useCopyText();
const [loading, setLoading] = useState(false);
const [list, setList] = useState<GatewayApikeyItem[]>([]);
const [updatingId, setUpdatingId] = useState<number | null>(null);
const { t } = useTranslation();
const copyText = useCopyText();
const [loading, setLoading] = useState(false);
const [list, setList] = useState<GatewayApikeyItem[]>([]);
const [updatingId, setUpdatingId] = useState<number | null>(null);
const loadList = useCallback(async () => {
setLoading(true);
try {
const res = await getGatewayApikeyList({ page: 1, pageSize: 20 });
const rows = Array.isArray(res.data?.list) ? res.data.list.map(normalizeGatewayApikeyItem).filter((item): item is GatewayApikeyItem => item != null) : [];
setList(rows);
try {
const res = await getGatewayApikeyList({ page: 1, pageSize: 20 });
const rows = Array.isArray(res.data?.list) ? res.data.list.map(normalizeGatewayApikeyItem).filter((item): item is GatewayApikeyItem => item != null) : [];
setList(rows);
} catch {
setList([]);
} finally {
@ -88,10 +88,10 @@ export default function GatewayApikeyDialog() {
const hasData = useMemo(() => list.length > 0, [list]);
const handleToggle = useCallback(
async (item: GatewayApikeyItem) => {
if (updatingId != null) return;
const nextEnabled = item.status !== 1;
const handleToggle = useCallback(
async (item: GatewayApikeyItem) => {
if (updatingId != null) return;
const nextEnabled = item.status !== 1;
setUpdatingId(item.id);
try {
await setGatewayApikeyStatus({
@ -129,25 +129,25 @@ export default function GatewayApikeyDialog() {
<div className="gateway_apikey_dialog__row gateway_apikey_dialog__row_user">
<span className="gateway_apikey_dialog__userCode">{item.code}</span>
<span
{/* <span
className={["gateway_apikey_dialog__switch", item.status === 1 ? "gateway_apikey_dialog__switch_on" : "gateway_apikey_dialog__switch_off", updatingId === item.id ? "gateway_apikey_dialog__switch_loading" : ""].join(" ")}
role="switch"
aria-checked={item.status === 1}
aria-label={t("gatewayApiKey.statusAria")}
onClick={() => { void handleToggle(item); }}
>
aria-checked={item.status === 1}
aria-label={t("gatewayApiKey.statusAria")}
onClick={() => { void handleToggle(item); }}
>
<span className="gateway_apikey_dialog__switchLabel">{item.status === 1 ? "ON" : "OFF"}</span>
<span className="gateway_apikey_dialog__switchKnob" />
</span>
</span> */}
</div>
<div className="gateway_apikey_dialog__detail">
<span className="gateway_apikey_dialog__detailLabel">Key</span>
<span className="gateway_apikey_dialog__detailValue gateway_apikey_dialog__detailValue_key">
{maskApikey(item.apikey)}
<button type="button" className="gateway_apikey_dialog__copyBtn" onClick={() => { void copyText(item.apikey); }} aria-label={t("gatewayApiKey.copyKey")}>
<img className="gateway_apikey_dialog__copyIcon" src={new URL("@/assets/images/ic_copy.png", import.meta.url).href} />
</button>
<span className="gateway_apikey_dialog__detailValue gateway_apikey_dialog__detailValue_key">
{maskApikey(item.apikey)}
<button type="button" className="gateway_apikey_dialog__copyBtn" onClick={() => { void copyText(item.apikey); }} aria-label={t("gatewayApiKey.copyKey")}>
<img className="gateway_apikey_dialog__copyIcon" src={new URL("@/assets/images/ic_copy.png", import.meta.url).href} />
</button>
</span>
</div>

32
src/layouts/globalPromptPanel/ComposerMediaSortableList.tsx

@ -1,8 +1,9 @@
import { useState, type CSSProperties, type DragEvent, type PointerEvent, type ReactNode } from "react";
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors, type DragEndEvent, type UniqueIdentifier } from "@dnd-kit/core";
import { DndContext, PointerSensor, useSensor, useSensors, type CollisionDetection, type DragEndEvent, type Modifier, type UniqueIdentifier } from "@dnd-kit/core";
import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useHorizontalWheelScroll } from "@/hooks/useHorizontalWheelScroll";
import type { ComposerMediaItem } from "./composerSections/CommonUploadPromptSection";
export type ComposerMediaSortableListItem = {
@ -53,6 +54,30 @@ class ComposerMediaPointerSensor extends PointerSensor {
];
}
const restrictComposerMediaSortingToHorizontalAxis: Modifier = ({ transform }) => ({
...transform,
y: 0,
});
const composerMediaHorizontalCollisionDetection: CollisionDetection = ({ collisionRect, droppableRects, droppableContainers }) => {
const collisionCenterX = collisionRect.left + collisionRect.width / 2;
return droppableContainers
.map((droppableContainer) => {
const rect = droppableRects.get(droppableContainer.id);
if (!rect) return null;
const rectCenterX = rect.left + rect.width / 2;
return {
id: droppableContainer.id,
data: {
droppableContainer,
value: Math.abs(collisionCenterX - rectCenterX),
},
};
})
.filter((item): item is NonNullable<typeof item> => item != null)
.sort((a, b) => a.data.value - b.data.value);
};
function ComposerMediaSortableItem({ id, index, enabled, children }: ComposerMediaSortableItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
@ -88,6 +113,7 @@ function ComposerMediaSortableItem({ id, index, enabled, children }: ComposerMed
export function ComposerMediaSortableList({ items, enabled, className, ariaLabel, onReorderMedia, onSortStart, onSortEnd, renderItem, childrenAfterItems }: ComposerMediaSortableListProps) {
const [isSorting, setIsSorting] = useState(false);
const uploadListWheelRef = useHorizontalWheelScroll({ enabled: !isSorting });
const sortableEnabled = enabled && items.length > 1 && typeof onReorderMedia === "function";
const itemIds = items.map((item) => item.id);
const sensors = useSensors(
@ -119,9 +145,9 @@ export function ComposerMediaSortableList({ items, enabled, className, ariaLabel
};
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={finishSorting}>
<DndContext sensors={sensors} collisionDetection={composerMediaHorizontalCollisionDetection} modifiers={[restrictComposerMediaSortingToHorizontalAxis]} autoScroll={false} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={finishSorting}>
<SortableContext items={itemIds} strategy={horizontalListSortingStrategy} disabled={!sortableEnabled}>
<div className={className} aria-label={ariaLabel}>
<div ref={uploadListWheelRef} className={className} aria-label={ariaLabel}>
{items.map((item) => (
<ComposerMediaSortableItem key={item.id} id={item.id} index={item.index} enabled={sortableEnabled}>
{renderItem(item.index, { isSorting })}

6
src/layouts/globalPromptPanel/composerComponents/ComposerFooter.tsx

@ -6,6 +6,7 @@ import { formatRangeParamDisplay, getRangeParamBounds, getRangeParamFillPercent,
import { ratioChipPreviewSize } from "../composerCommon";
import type { AiModelItem, AspectRatioItem, ComposerParamField } from "../types";
import { isRatioField, resolveVoiceRangeParamDisplayName } from "../utils";
import { ComposerModelTags } from "./ComposerModelTags";
function getDropdownPopupContainer() {
return document.body;
@ -102,7 +103,10 @@ export function ComposerFooter({
>
<img className="layoutShell__composerModelDropdownItemIcon" src={resolveAiModelIconSrc(item.icon)} />
<span className="layoutShell__composerModelDropdownItemText">
<span className="layoutShell__composerModelDropdownItemName">{item.name}</span>
<span className="layoutShell__composerModelDropdownItemName">
<span className="layoutShell__composerModelDropdownItemNameText">{item.name}</span>
<ComposerModelTags tags={item.tags} />
</span>
<span className="layoutShell__composerModelDropdownItemDesc">{item.description}</span>
</span>
</button>

33
src/layouts/globalPromptPanel/composerComponents/ComposerModelTags.tsx

@ -0,0 +1,33 @@
import type { CSSProperties } from "react";
import type { AiModelTagItem } from "../types";
import { normalizeAiModelTags } from "../utils";
const DEFAULT_MODEL_TAG_COLOR = "#fa51a2";
const DEFAULT_MODEL_TAG_TEXT_COLOR = "#ffffff";
type ComposerModelTagsProps = {
tags?: readonly AiModelTagItem[] | string | null;
};
function getModelTagStyle(tag: AiModelTagItem): CSSProperties {
return {
backgroundColor: tag.bgColor?.trim() || DEFAULT_MODEL_TAG_COLOR,
color: tag.textColor?.trim() || DEFAULT_MODEL_TAG_TEXT_COLOR,
};
}
export function ComposerModelTags({ tags }: ComposerModelTagsProps) {
const visibleTags = normalizeAiModelTags(tags) ?? [];
if (visibleTags.length === 0) return null;
return (
<div className="layoutShell__composerModelDropdownTags">
{visibleTags.map((tag, index) => (
<div key={`${tag.label}-${tag.bgColor ?? ""}-${tag.textColor ?? ""}-${index}`} className="layoutShell__composerModelDropdownTag" style={getModelTagStyle(tag)}>
{tag.label}
</div>
))}
</div>
);
}

5
src/layouts/globalPromptPanel/composerSections/HeadTailUploadPromptSection.tsx

@ -8,6 +8,7 @@
import { useRef, type ChangeEvent, type MutableRefObject, type MouseEvent } from "react";
import { Input } from "@arco-design/web-react";
import { useTranslation } from "react-i18next";
import { HorizontalWheelScroll } from "@/components/HorizontalWheelScroll";
import { UploadPendingIndicator } from "@/components/UploadPendingIndicator";
import type { ComposerMediaItem } from "./CommonUploadPromptSection";
import { ComposerUploadMediaPreview } from "./ComposerUploadMediaPreview";
@ -129,9 +130,9 @@ export function HeadTailUploadPromptSection({
renderItem={(idx, { isSorting }) => renderHeadTailSlot(idx as 0 | 1, isSorting)}
/>
) : (
<div className="layoutShell__composerUploadList" aria-label={t("composer.aria.headTailUpload")}>
<HorizontalWheelScroll className="layoutShell__composerUploadList" aria-label={t("composer.aria.headTailUpload")}>
{headTailSlots.map((idx) => renderHeadTailSlot(idx, false))}
</div>
</HorizontalWheelScroll>
)}
<input ref={uploadInputRef} className="layoutShell__composerUploadInput" type="file" accept={uploadAccept} multiple={false} onChange={onUploadInputChange} tabIndex={-1} aria-hidden="true" />
</>

43
src/layouts/globalPromptPanel/globalPromptPanel.scss

@ -1019,21 +1019,56 @@ $composer-collapsed-gap: 8px;
min-width: 0;
display: flex;
flex-direction: column;
flex: 1 1 auto;
height: 35px;
}
.layoutShell__composerModelDropdownItemName {
height: 15px;
width: 100%;
min-width: 0;
height: 18px;
color: #333333;
font-size: 14px;
font-weight: 500;
line-height: 26px;
line-height: 18px;
display: flex;
align-items: center;
gap: 5px;
overflow: hidden;
}
.layoutShell__composerModelDropdownItemNameText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.layoutShell__composerModelDropdownTags {
display: flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
max-width: 50%;
}
.layoutShell__composerModelDropdownTag {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
height: 14px;
padding: 0 5px;
border-radius: 100px;
box-sizing: border-box;
color: #ffffff;
font-size: 8px;
font-weight: 400;
line-height: 1;
}
.layoutShell__composerModelDropdownItemDesc {
margin-top: 5px;
margin-top: 2px;
height: 15px;
color: #999999;
font-size: 12px;
@ -1302,4 +1337,4 @@ $composer-collapsed-gap: 8px;
display: block;
flex-shrink: 0;
}
}
}

2
src/layouts/globalPromptPanel/globalPromptPanel.tsx

@ -326,7 +326,7 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false,
const composerMediaUploadEnabled = supportedUploadMimePrefixes.length > 0;
const taskSubTypePlaceholder = useMemo(() => {
const row = getTaskSubTypeRow(activeTaskSubType);
if (!row || !("placeholder" in row)) return undefined;
if (!row || typeof row !== "object" || !("placeholder" in row)) return undefined;
return t(row.placeholder);
}, [activeTaskSubType, t]);

8
src/layouts/globalPromptPanel/types.ts

@ -57,6 +57,13 @@ export type ModelDisplayDimensions = {
dimensions?: Record<string, ModelDimensionField>;
};
/** 模型列表展示标签。 */
export type AiModelTagItem = {
label: string;
bgColor?: string;
textColor?: string;
};
/** AI 模型项(接口字段)。 */
export type AiModelItem = {
id?: string | number;
@ -75,6 +82,7 @@ export type AiModelItem = {
/** 音乐模型是否支持纯音乐(无歌词)创作。 */
isSupportPureMusic?: boolean;
billingMethod?: number;
tags?: AiModelTagItem[] | string | null;
/** 图片上传数量限制 [最小值, 最大值] 闭区间;空数组表示不限制。 */
uploadImageLimit?: number[];
/** 视频上传数量限制 [最小值, 最大值] 闭区间;空数组表示不限制。 */

47
src/layouts/globalPromptPanel/utils.ts

@ -5,7 +5,7 @@
import type { ComposerMediaItem, ComposerMediaType } from "./composerSections/CommonUploadPromptSection";
import { getQualityTaskParamValue, getRangeParamBounds, normalizeRangeParamValue, parseExtraTaskParams, splitRangeParamsForTaskSubmit, QUALITY_TASK_PARAM_KEY } from "./composerRangeParam";
import { buildSubjectTagName, parseReferencePromptAstFromUnknown } from "./composerSections/referencePromptAst";
import type { AiCategoryItem, AiModelItem, AspectRatioItem, ComposerParamField, GlobalPromptDraft, GlobalPromptDraftBucket, GlobalPromptDraftStoreV2, ReferenceSubjectItem, ModelDimensionOption, ModelDisplayDimensions } from "./types";
import type { AiCategoryItem, AiModelItem, AiModelTagItem, AspectRatioItem, ComposerParamField, GlobalPromptDraft, GlobalPromptDraftBucket, GlobalPromptDraftStoreV2, ReferenceSubjectItem, ModelDimensionOption, ModelDisplayDimensions } from "./types";
// ---------------------------------------------------------------------------
// 任务与 Tab 元数据
@ -764,11 +764,48 @@ function normalizeDraftBucket(bucket: unknown): GlobalPromptDraftBucket {
*/
export function normalizeAiModelItem<T extends AiModelItem>(item: T): T {
const raw = item.displayDimensions as ModelDisplayDimensions | string | undefined;
const normalizedTags = normalizeAiModelTags(item.tags);
if (typeof raw === "string" && raw.trim() !== "") {
return { ...item, displayDimensions: parseDisplayDimensions(raw) };
return { ...item, displayDimensions: parseDisplayDimensions(raw), tags: normalizedTags };
}
if (raw != null && typeof raw === "object") return { ...item, tags: normalizedTags };
return { ...item, displayDimensions: {}, tags: normalizedTags };
}
export function normalizeAiModelTags(rawTags: unknown): AiModelTagItem[] | undefined {
const parsedTags = parseAiModelTags(rawTags);
if (!Array.isArray(parsedTags)) return undefined;
const tags = parsedTags
.map((rawTag): AiModelTagItem | null => {
if (!rawTag || typeof rawTag !== "object") return null;
const row = rawTag as Record<string, unknown>;
const label = typeof row.label === "string" ? row.label.trim() : "";
if (!label) return null;
const bgColor = typeof row.bgColor === "string" ? row.bgColor.trim() : "";
const textColor = typeof row.textColor === "string" ? row.textColor.trim() : "";
return {
label,
...(bgColor ? { bgColor } : {}),
...(textColor ? { textColor } : {}),
};
})
.filter((tag): tag is AiModelTagItem => tag != null);
return tags.length > 0 ? tags : undefined;
}
function parseAiModelTags(rawTags: unknown): unknown {
if (rawTags == null) return undefined;
if (Array.isArray(rawTags)) return rawTags;
if (typeof rawTags !== "string") return undefined;
const value = rawTags.trim();
if (!value) return undefined;
try {
return JSON.parse(value) as unknown;
} catch {
return undefined;
}
if (raw != null && typeof raw === "object") return item;
return { ...item, displayDimensions: {} };
}
/**
@ -1173,7 +1210,7 @@ export function buildComposerTaskSubmitPayload(params: BuildComposerTaskSubmitPa
return {
subType: params.activeTaskSubType,
...buildGenTaskModelPayload(params.activeModelItem),
type: getTaskSubTypeRow(params.activeTaskSubType)?.type,
type: getTaskSubTypeRow(params.activeTaskSubType)?.type ,
...taskParams,
...(Object.keys(extraTaskParams).length > 0 ? { extraTaskParams } : {}),
};

93
src/pages/CharactersCreate/index.scss

@ -553,17 +553,47 @@ $characters-create-skeleton-gradient: linear-gradient(90deg, rgba(0, 0, 0, 0.07)
margin-top: 0;
border: 0;
border-radius: 26px;
background: #ffffff;
min-height: 150px;
padding: 15px 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
.charactersCreate__composerRow {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
background: #ffffff;
min-height: 150px;
padding: 15px 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
transition: min-height 0.18s ease, box-shadow 0.18s ease;
.charactersCreate__composerExpandPromptBtn {
position: absolute;
top: 10px;
right: 10px;
z-index: 5;
width: 20px;
height: 20px;
border: none;
border-radius: 5px;
background: transparent;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
img {
width: 20px;
height: 20px;
display: block;
object-fit: contain;
}
}
.charactersCreate__composerExpandPromptBtn:hover {
background: #f0f0f0;
}
.charactersCreate__composerRow {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: flex-start;
gap: 10px;
border-radius: 9px;
@ -975,18 +1005,35 @@ $characters-create-skeleton-gradient: linear-gradient(90deg, rgba(0, 0, 0, 0.07)
}
.charactersCreate__composerTextarea::placeholder {
color: #999999;
font-weight: 500;
}
.charactersCreate__composerTextarea .arco-textarea::placeholder {
color: #999999;
font-weight: 500;
}
}
.charactersCreate__composerToolbar {
display: flex;
color: #999999;
font-weight: 500;
}
.charactersCreate__composerTextarea .arco-textarea::placeholder {
color: #999999;
font-weight: 500;
}
}
&.charactersCreate__composerPanel_promptExpanded {
min-height: min(60vh, 520px);
box-shadow: 0 2px 12px rgba(51, 51, 51, 0.08);
.charactersCreate__composerRow {
flex: 1 1 auto;
min-height: min(42vh, 360px);
padding-right: 18px;
}
.charactersCreate__composerTextarea,
.charactersCreate__composerTextarea .arco-textarea {
min-height: min(42vh, 360px) !important;
max-height: min(42vh, 360px) !important;
}
}
.charactersCreate__composerToolbar {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;

47
src/pages/CharactersCreate/index.tsx

@ -3,8 +3,9 @@ import { Dropdown, Input, Message, Tooltip } from "@arco-design/web-react";
import { useLocation } from "react-router-dom";
import { createGenTask, createResultTask, deleteCharacterFromCreatePage, getAiCategoryList, getAiModelList, getCharacterList, getMaterialLibraryCategoryList, getMaterialLibraryList, getTaskList, getTemplateList, recordDepointEvent } from "@/api/api";
import { openCharacterPickerDialog, openCopyrightDepositDialog, openInsufficientPointsDialog, openMaterialPickerDialog } from "@/dialog";
import { openCharacterPickerDialog, openCopyrightDepositDialog, openInsufficientPointsDialog, openMaterialPickerDialog } from "@/dialog";
import { ratioChipPreviewSize, uploadComposerImages } from "@/layouts/globalPromptPanel/composerCommon";
import { COMPOSER_ASSET } from "@/layouts/globalPromptPanel/composerAssets";
import { ComposerMediaSortableList } from "@/layouts/globalPromptPanel/ComposerMediaSortableList";
import { formatOriginalDiscountFoldLabel, useGenerateCost } from "@/layouts/globalPromptPanel/useGenerateCost";
import type { AiModelItem, AspectRatioItem } from "@/layouts/globalPromptPanel/types";
@ -189,8 +190,9 @@ export default function CharactersCreate() {
const [activeModelItem, setActiveModelItem] = useState<AiModelItem | null>(null);
const [selectedParamValues, setSelectedParamValues] = useState<Record<string, string | number>>({});
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const [paramMenuOpen, setParamMenuOpen] = useState(false);
// 用户输入:上传图、提示词。
const [paramMenuOpen, setParamMenuOpen] = useState(false);
const [isComposerPromptExpanded, setIsComposerPromptExpanded] = useState(false);
// 用户输入:上传图、提示词。
const [images, setImages] = useState<UploadImageItem[]>([]);
const [uploadPendingCount, setUploadPendingCount] = useState(0);
const uploadPendingCountRef = useRef(0);
@ -811,14 +813,18 @@ export default function CharactersCreate() {
const generateCost = generateCostResult.cost;
const generateDiscountFoldLabel = formatOriginalDiscountFoldLabel(generateCostResult.originalDiscount);
const showGenerateDiscountBadge = generateDiscountFoldLabel != null;
const generateDiscountBadgeValue = useMemo(() => {
if (generateDiscountFoldLabel == null) return null;
const lang = i18n.resolvedLanguage ?? i18n.language;
if (lang.startsWith("zh")) return generateDiscountFoldLabel;
return String(Math.round((1 - generateCostResult.originalDiscount) * 100));
}, [generateCostResult.originalDiscount, generateDiscountFoldLabel, i18n.language, i18n.resolvedLanguage]);
const buildTaskParamPayload = useCallback(() => buildComposerTaskParamPayload(modelParamFields, selectedParamValues), [modelParamFields, selectedParamValues]);
const generateDiscountBadgeValue = useMemo(() => {
if (generateDiscountFoldLabel == null) return null;
const lang = i18n.resolvedLanguage ?? i18n.language;
if (lang.startsWith("zh")) return generateDiscountFoldLabel;
return String(Math.round((1 - generateCostResult.originalDiscount) * 100));
}, [generateCostResult.originalDiscount, generateDiscountFoldLabel, i18n.language, i18n.resolvedLanguage]);
const composerPromptExpandActionLabel = isComposerPromptExpanded ? t("composer.actions.collapseInputPanel") : t("composer.actions.expandInputPanel");
const handleComposerPromptExpandToggle = useCallback(() => {
setIsComposerPromptExpanded((expanded) => !expanded);
}, []);
const buildTaskParamPayload = useCallback(() => buildComposerTaskParamPayload(modelParamFields, selectedParamValues), [modelParamFields, selectedParamValues]);
// 点击上传卡片时,触发隐藏的 input 选择文件。
const handleUploadCardClick = useCallback(() => {
@ -1595,11 +1601,16 @@ export default function CharactersCreate() {
{/* 中:创作区 */}
<main className="charactersCreate__center">
{taskStageContent}
<div className="charactersCreate__composer">
<div className="charactersCreate__composerPanel">
<div className="charactersCreate__composerRow" onDragOver={preventDefaultComposerDragOver} onDragEnter={preventDefaultComposerDragOver} onDrop={handleComposerDrop}>
{taskStageContent}
<div className="charactersCreate__composer">
<div className={cx("charactersCreate__composerPanel", isComposerPromptExpanded && "charactersCreate__composerPanel_promptExpanded")}>
<Tooltip content={composerPromptExpandActionLabel}>
<button type="button" className="charactersCreate__composerExpandPromptBtn" aria-label={composerPromptExpandActionLabel} aria-pressed={isComposerPromptExpanded} onClick={handleComposerPromptExpandToggle}>
<img src={isComposerPromptExpanded ? COMPOSER_ASSET.icCollapsed : COMPOSER_ASSET.icExtension} alt="" />
</button>
</Tooltip>
<div className="charactersCreate__composerRow" onDragOver={preventDefaultComposerDragOver} onDragEnter={preventDefaultComposerDragOver} onDrop={handleComposerDrop}>
<div className="charactersCreate__promptLibraryWrap">
<button
type="button"
@ -1693,8 +1704,8 @@ export default function CharactersCreate() {
/>
)}
<input ref={uploadInputRef} className="charactersCreate__composerUploadInput" type="file" accept="image/*" multiple onChange={handleUploadInputChange} tabIndex={-1} aria-hidden="true" />
<Input.TextArea className="charactersCreate__composerTextarea" value={chatPrompt} onChange={handleChatPromptChange} placeholder={t("pages.charactersCreate.promptPlaceholder")} aria-label={t("composer.aria.promptInput")} autoSize={{ minRows: 1, maxRows: 3 }} />
</div>
<Input.TextArea className="charactersCreate__composerTextarea" value={chatPrompt} onChange={handleChatPromptChange} placeholder={t("pages.charactersCreate.promptPlaceholder")} aria-label={t("composer.aria.promptInput")} autoSize={isComposerPromptExpanded ? { minRows: 8, maxRows: 12 } : { minRows: 1, maxRows: 3 }} />
</div>
<div className="charactersCreate__composerToolbar">
<div className="charactersCreate__composerChips">

6
src/pages/Voice/components/music/VoiceMusicPanel.tsx

@ -8,6 +8,7 @@ import { addPostLike, delPostLike, createGenTask, deleteAsset, getAiModelList, g
import { DeleteConfirmModal } from "@/components/DeleteConfirmModal";
import { HorizontalWheelScrollRow } from "@/components/HorizontalWheelScroll";
import { ensureSufficientPoints, uploadComposerMedia } from "@/layouts/globalPromptPanel/composerCommon";
import { ComposerModelTags } from "@/layouts/globalPromptPanel/composerComponents/ComposerModelTags";
import type { AiModelItem } from "@/layouts/globalPromptPanel/types";
import { useGenerateCost } from "@/layouts/globalPromptPanel/useGenerateCost";
import { normalizeAiModelItem, buildGenTaskModelPayload } from "@/layouts/globalPromptPanel/utils";
@ -1028,7 +1029,10 @@ function MusicComposerPanel({ onTaskCreated }: MusicComposerPanelProps) {
>
<img className="layoutShell__composerModelDropdownItemIcon" src={resolveModelIcon(item.icon)} alt="" aria-hidden />
<span className="layoutShell__composerModelDropdownItemText">
<span className="layoutShell__composerModelDropdownItemName">{item.name}</span>
<span className="layoutShell__composerModelDropdownItemName">
<span className="layoutShell__composerModelDropdownItemNameText">{item.name}</span>
<ComposerModelTags tags={item.tags} />
</span>
{item.description ? <span className="layoutShell__composerModelDropdownItemDesc">{item.description}</span> : null}
</span>
</button>

26
src/pages/Voice/components/speech/VoiceSpeechPanel.tsx

@ -29,6 +29,7 @@ import { openAssetDetailsDialog, openVoiceLibraryPickerDialog } from "@/dialog";
import { createAssetDetailNavigation } from "@/dialog/asset-details/navigation";
import { lockLayoutContentScroll, unlockLayoutContentScroll } from "@/layouts/layoutContentModalMount";
import { ensureSufficientPoints } from "@/layouts/globalPromptPanel/composerCommon";
import { ComposerModelTags } from "@/layouts/globalPromptPanel/composerComponents/ComposerModelTags";
import {
applyRangeParamValues,
formatRangeParamDisplay,
@ -538,6 +539,10 @@ function stripSpeechCursorZwsp(text: string): string {
return text.replaceAll(SPEECH_CURSOR_ZWSP, "");
}
function sanitizeSpeechChatPromptForSubmit(text: string): string {
return stripSpeechCursorZwsp(text).replace(/[\s\uFEFF]+/gu, "");
}
function isInlineAtomNode(node: ProseMirrorNode): boolean {
return SPEECH_INLINE_ATOM_NODE_NAMES.includes(node.type.name as (typeof SPEECH_INLINE_ATOM_NODE_NAMES)[number]);
}
@ -2927,7 +2932,7 @@ export function VoiceSpeechPanel() {
const [chatPrompt, setChatPrompt] = useState("");
const [batchSize, setBatchSize] = useState(QUANTITY_MIN);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(true);
const [sideTab, setSideTab] = useState<SpeechSideTab>("debug");
const [aiModels, setAiModels] = useState<AiModelItem[]>([]);
const [activeModelItem, setActiveModelItem] = useState<AiModelItem | null>(null);
@ -2945,6 +2950,7 @@ export function VoiceSpeechPanel() {
const [cloneModalOpen, setCloneModalOpen] = useState(false);
const promptCount = chatPrompt.length;
const submittedChatPrompt = sanitizeSpeechChatPromptForSubmit(chatPrompt);
const modelParamFields = useMemo(() => {
const fields = getComposerParamFieldsForModel(activeModelItem);
return fields.filter((field) => field.key !== "batchSize");
@ -2977,12 +2983,12 @@ export function VoiceSpeechPanel() {
return {
...composerRest,
origin: VOICE_MUSIC_ORIGIN,
chatPrompt: chatPrompt.trim(),
chatPrompt: submittedChatPrompt,
batchSize,
...(selectedVoiceItem?.voiceId ? { voiceId: selectedVoiceItem.voiceId } : {}),
...(Object.keys(extraTaskParams).length > 0 ? { extraTaskParams } : {}),
};
}, [activeModelItem, batchSize, chatPrompt, modelParamFields, selectedParamValues, selectedSpeechLang, selectedVoiceItem?.voiceId]);
}, [activeModelItem, batchSize, modelParamFields, selectedParamValues, selectedSpeechLang, selectedVoiceItem?.voiceId, submittedChatPrompt]);
const generateCostParamValues = useMemo(() => ({ ...selectedParamValues, batchSize }), [batchSize, selectedParamValues]);
@ -2991,11 +2997,11 @@ export function VoiceSpeechPanel() {
selectedParamValues: generateCostParamValues,
taskPayload: createGenTaskPayload ?? undefined,
activeTaskSubType: VOICE_SPEECH_SUB_TYPE,
chatPrompt,
chatPrompt: submittedChatPrompt,
});
const displayGenerateCost = generateCostResult.cost;
const canGenerate = chatPrompt.trim().length > 0 && selectedVoiceItem != null && agreedToTerms && createGenTaskPayload != null;
const canGenerate = submittedChatPrompt.length > 0 && selectedVoiceItem != null && agreedToTerms && createGenTaskPayload != null;
const handleOpenVoiceLibrary = useCallback(() => {
void openVoiceLibraryPickerDialog({
@ -3109,8 +3115,8 @@ export function VoiceSpeechPanel() {
};
const handleGenerate = async () => {
if (!canGenerate || createGenTaskInFlightRef.current || createGenTaskPayload == null) return;
if (chatPrompt.trim() === "") {
if (createGenTaskInFlightRef.current) return;
if (submittedChatPrompt.length === 0) {
Message.error(t("pages.voice.speech.promptRequired"));
return;
}
@ -3122,6 +3128,7 @@ export function VoiceSpeechPanel() {
Message.error(t("composer.voiceLibrary.required"));
return;
}
if (!canGenerate || createGenTaskPayload == null) return;
createGenTaskInFlightRef.current = true;
setIsCreatingGenTask(true);
@ -3368,7 +3375,10 @@ export function VoiceSpeechPanel() {
>
<img className="layoutShell__composerModelDropdownItemIcon" src={resolveModelIcon(item.icon)} alt="" aria-hidden />
<span className="layoutShell__composerModelDropdownItemText">
<span className="layoutShell__composerModelDropdownItemName">{item.name}</span>
<span className="layoutShell__composerModelDropdownItemName">
<span className="layoutShell__composerModelDropdownItemNameText">{item.name}</span>
<ComposerModelTags tags={item.tags} />
</span>
{item.description ? <span className="layoutShell__composerModelDropdownItemDesc">{item.description}</span> : null}
</span>
</button>

7
src/utils/horizontalWheelScroll.ts

@ -9,9 +9,10 @@ export type HorizontalWheelScrollOptions = {
* `overflow-x: auto`
*/
export function attachHorizontalWheelScroll(el: HTMLElement, options: HorizontalWheelScrollOptions = {}): () => void {
const onWheel = (event: WheelEvent) => {
const { enabled = true, ignoreWithin } = options;
if (!enabled) return;
const onWheel = (event: WheelEvent) => {
const { enabled = true, ignoreWithin } = options;
if (event.defaultPrevented) return;
if (!enabled) return;
if (ignoreWithin && event.target instanceof Element && event.target.closest(ignoreWithin)) return;

Loading…
Cancel
Save