import { X } from 'lucide-react'; import type { ChangeEvent } from 'react'; import type { CSSProperties } from 'react'; import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../../data/affinityLevels'; import { buildCustomWorldPlayableCharacters, ROLE_TEMPLATE_CHARACTERS, } from '../../data/characterPresets'; import { normalizeCustomWorldLandmarks, } from '../../data/customWorldSceneGraph'; import { getAllCustomWorldSceneImages, resolveCustomWorldCampSceneImage, resolveCustomWorldLandmarkImage, } from '../../data/customWorldVisuals'; import { RESOLVED_ENTITY_X_METERS } from '../../data/sceneEncounterPreviews'; import { buildEncounterFromSceneNpc } from '../../data/scenePresets'; import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClient'; import { fetchJson } from '../../editor/shared/jsonClient'; import { useCombatFlow } from '../../hooks/useCombatFlow'; import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow'; import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story'; import { useRpgSessionBootstrap } from '../../hooks/rpg-session'; import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts'; import type { CustomWorldSceneImageResult } from '../../services/aiTypes'; import { resolveCustomWorldCampScene } from '../../services/customWorldCamp'; import { buildDefaultCustomWorldCoverProfile, resolveCustomWorldCoverPresentation, } from '../../services/customWorldCover'; import { type CustomWorldCoverAssetResult, generateCustomWorldCoverImage, uploadCustomWorldCoverImage, } from '../../services/customWorldCoverAssetService'; import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient'; import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine'; import { AnimationState, type Character, type CharacterAnimationConfig, type CustomWorldCoverCropRect, type CustomWorldCoverProfile, CustomWorldLandmark, CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, type CustomWorldRoleInitialItem, type CustomWorldRoleRelation, type CustomWorldRoleSkill, CustomWorldSceneConnection, type ItemRarity, type SceneActAdvanceRule, type SceneActBlueprint, type SceneActStage, type SceneChapterBlueprint, type SceneNpc, type ScenePresetInfo, WorldType, } from '../../types'; import { type CharacterAnimationGenerationPayload, generateCharacterAnimationDraft, publishCharacterAnimationAssets, } from '../asset-studio/characterAssetWorkflowPersistence'; import { useAuthUi } from '../auth/AuthUiContext'; import { CharacterAnimator } from '../CharacterAnimator'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { buildDefaultCustomWorldNpcVisual } from '../customWorldNpcVisualDefaults'; import { CustomWorldNpcPortrait, CustomWorldNpcVisualEditor, } from '../CustomWorldNpcVisualEditor'; import { RpgCreationRoleAssetStudioModal } from '../rpg-creation-asset-studio/RpgCreationRoleAssetStudioModal'; import { RoleCharacterSprite, SceneEncounterNpcSprite, } from '../game-canvas/GameCanvasShared'; import { PixelIcon } from '../PixelIcon'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgRuntimeShell } from '../rpg-runtime-shell'; import { createLandmarkDraft, createPlayableNpcDraft, createStoryNpcDraft, resolveEditableLandmark, resolveEditablePlayableNpc, resolveEditableStoryNpc, } from './rpgCreationResultFormMapper'; export type RpgCreationEditorTarget = | { kind: 'world' } | { kind: 'cover' } | { kind: 'camp' } | { kind: 'playable'; mode: 'create' } | { kind: 'playable'; mode: 'edit'; id: string } | { kind: 'story'; mode: 'create' } | { kind: 'story'; mode: 'edit'; id: string } | { kind: 'landmark'; mode: 'create' } | { kind: 'landmark'; mode: 'edit'; id: string }; export interface RpgCreationEntityEditorModalProps { profile: CustomWorldProfile; target: RpgCreationEditorTarget | null; onClose: () => void; onProfileChange: (profile: CustomWorldProfile) => void; } function getAnimationPreviewFrameStyle( _config: CharacterAnimationConfig | null | undefined, targetSize: number, ) { return { width: `${targetSize}px`, height: `${targetSize}px`, maxWidth: '100%', maxHeight: '100%', } satisfies CSSProperties; } const [ BACKSTORY_UNLOCK_AFFINITY_EASED, BACKSTORY_UNLOCK_AFFINITY_FRIENDLY, BACKSTORY_UNLOCK_AFFINITY_TRUSTED, BACKSTORY_UNLOCK_AFFINITY_CLOSE, ] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS; const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [ { value: 'common', label: '普通' }, { value: 'uncommon', label: '优秀' }, { value: 'rare', label: '稀有' }, { value: 'epic', label: '史诗' }, { value: 'legendary', label: '传说' }, ]; const ITEM_RARITY_LABELS: Record = { common: '普通', uncommon: '优秀', rare: '稀有', epic: '史诗', legendary: '传说', }; const MIN_SCENE_ACT_COUNT = 2; const MAX_SCENE_ACT_COUNT = 5; const DEFAULT_SCENE_ACT_COUNT = 3; const SCENE_ACT_ROLE_SLOT_COUNT = 3; const CARDINAL_CONNECTION_DIRECTIONS = [ 'north', 'east', 'south', 'west', ] as const; type CardinalConnectionDirection = (typeof CARDINAL_CONNECTION_DIRECTIONS)[number]; const CARDINAL_CONNECTION_LABELS: Record = { north: '北', east: '东', south: '南', west: '西', }; function slugify(value: string) { const normalized = value .trim() .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') .replace(/^-+|-+$/g, ''); return normalized || 'entry'; } function createEntryId(prefix: string, label: string, seed: number) { return `${prefix}-${slugify(label || `${prefix}-${seed}`)}-${seed.toString(36)}`; } function parseCommaText(value: string) { return [ ...new Set( value .split(/[\n,,]/u) .map((item) => item.trim()) .filter(Boolean), ), ]; } function dedupeTextValues(values: Array) { return [ ...new Set( values .map((value) => value?.trim() ?? '') .filter(Boolean), ), ]; } function compactTextList(values: Array) { return values.map((value) => value?.trim() ?? '').filter(Boolean); } function moveArrayItem(values: T[], fromIndex: number, toIndex: number) { if ( fromIndex < 0 || toIndex < 0 || fromIndex >= values.length || toIndex >= values.length || fromIndex === toIndex ) { return values; } const nextValues = [...values]; const [movedItem] = nextValues.splice(fromIndex, 1); if (typeof movedItem === 'undefined') { return values; } nextValues.splice(toIndex, 0, movedItem); return nextValues; } function buildSceneActEncounterSlotIds(encounterNpcIds: string[]) { const compactIds = dedupeTextValues(encounterNpcIds).slice( 0, SCENE_ACT_ROLE_SLOT_COUNT, ); return Array.from({ length: SCENE_ACT_ROLE_SLOT_COUNT }, (_unused, index) => compactIds[index] ?? null, ) as Array; } function compactSceneActEncounterSlotIds(slotIds: Array) { return dedupeTextValues(slotIds).slice(0, SCENE_ACT_ROLE_SLOT_COUNT); } function assignSceneActEncounterSlotId( encounterNpcIds: string[], slotIndex: number, nextNpcId: string | null, ) { const nextSlots = buildSceneActEncounterSlotIds(encounterNpcIds); const normalizedNpcId = nextNpcId?.trim() || null; if (slotIndex < 0 || slotIndex >= nextSlots.length) { return compactSceneActEncounterSlotIds(nextSlots); } if (!normalizedNpcId) { nextSlots.splice(slotIndex, 1); nextSlots.push(null); return compactSceneActEncounterSlotIds(nextSlots); } return compactSceneActEncounterSlotIds( nextSlots.map((slotNpcId, index) => { if (index === slotIndex) { return normalizedNpcId; } return slotNpcId === normalizedNpcId ? null : slotNpcId; }), ); } function buildSceneActStageCoverage(index: number, actCount: number): SceneActStage[] { if (actCount <= 2) { return index === 0 ? ['opening', 'expansion'] : ['turning_point', 'climax', 'aftermath']; } if (actCount === 3) { if (index === 0) return ['opening']; if (index === 1) return ['expansion', 'turning_point']; return ['climax', 'aftermath']; } if (actCount === 4) { if (index === 0) return ['opening']; if (index === 1) return ['expansion']; if (index === 2) return ['turning_point']; return ['climax', 'aftermath']; } if (index === 0) return ['opening']; if (index === 1) return ['expansion']; if (index === 2) return ['turning_point']; if (index === 3) return ['climax']; return ['aftermath']; } function buildSceneActAdvanceRule( index: number, actCount: number, ): SceneActAdvanceRule { if (index === 0) { return 'after_primary_contact'; } if (index >= actCount - 1) { return 'after_chapter_resolution'; } return 'after_active_step_complete'; } function buildDefaultSceneActTitle(index: number) { return `第${index + 1}幕`; } function buildDefaultSceneActBlueprint(params: { sceneId: string; sceneName: string; sceneSummary: string; encounterNpcIds: string[]; backgroundImageSrc?: string | null; linkedThreadIds?: string[]; index: number; actCount: number; }): SceneActBlueprint { const encounterNpcIds = dedupeTextValues(params.encounterNpcIds).slice(0, 1); const actTitle = buildDefaultSceneActTitle(params.index); const sceneLabel = params.sceneName.trim() || '当前场景'; const sceneSummary = params.sceneSummary.trim(); const stageCoverage = buildSceneActStageCoverage(params.index, params.actCount); const actSummary = params.index === 0 ? `玩家会在${sceneLabel}接住这一章的开场入口。` : params.index >= params.actCount - 1 ? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。` : `${sceneLabel}的主要压力会在这一幕继续加深。`; return { id: `${params.sceneId}-act-${params.index + 1}`, sceneId: params.sceneId, title: actTitle, summary: actSummary, stageCoverage, // 幕背景画面描述应来自草稿生成阶段的大模型输出,前端缺失时只留空,避免展示规则拼接文本。 backgroundPromptText: '', backgroundImageSrc: params.backgroundImageSrc || undefined, encounterNpcIds, primaryNpcId: encounterNpcIds[0] ?? '', linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []), advanceRule: buildSceneActAdvanceRule(params.index, params.actCount), actGoal: params.index === 0 ? `先在${sceneLabel}接住当前局面` : params.index >= params.actCount - 1 ? `把${sceneLabel}这一章收束并抛出下一步` : `继续推进${sceneLabel}的核心矛盾`, transitionHook: params.index === 0 ? '和主角色完成首次有效接触后,局势会继续加压。' : params.index >= params.actCount - 1 ? '这一幕结束后,需要把后续方向明确抛给玩家。' : '完成当前主动推进后,这一幕会转向下一层压力。', }; } function buildDefaultSceneChapterBlueprint(params: { landmark: CustomWorldLandmark; fallbackImageSrc?: string | null; chapterId?: string; chapterTitle?: string; chapterSummary?: string; linkedThreadIds?: string[]; linkedLandmarkIds?: string[]; actCount?: number; }) { const actCount = Math.min( MAX_SCENE_ACT_COUNT, Math.max(MIN_SCENE_ACT_COUNT, params.actCount ?? DEFAULT_SCENE_ACT_COUNT), ); return { id: params.chapterId?.trim() || `scene-chapter-${params.landmark.id}`, sceneId: params.landmark.id, title: params.chapterTitle?.trim() || params.landmark.name.trim() || '场景章节', summary: params.chapterSummary?.trim() || params.landmark.description.trim(), linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []), linkedLandmarkIds: dedupeTextValues([ params.landmark.id, ...(params.linkedLandmarkIds ?? []), ]), acts: Array.from({ length: actCount }, (_unused, index) => buildDefaultSceneActBlueprint({ sceneId: params.landmark.id, sceneName: params.landmark.name, sceneSummary: params.landmark.description, encounterNpcIds: params.landmark.sceneNpcIds, backgroundImageSrc: params.fallbackImageSrc, linkedThreadIds: params.linkedThreadIds, index, actCount, }), ), } satisfies SceneChapterBlueprint; } function sanitizeSceneChapterBlueprint(params: { chapter: SceneChapterBlueprint | null | undefined; landmark: CustomWorldLandmark; fallbackImageSrc?: string | null; }) { const fallbackChapter = buildDefaultSceneChapterBlueprint({ landmark: params.landmark, fallbackImageSrc: params.fallbackImageSrc, chapterId: params.chapter?.id, chapterTitle: params.chapter?.title, chapterSummary: params.chapter?.summary, linkedThreadIds: params.chapter?.linkedThreadIds, linkedLandmarkIds: params.chapter?.linkedLandmarkIds, actCount: params.chapter?.acts.length, }); const rawActs = params.chapter?.acts ?? []; const chapterEncounterNpcIds = dedupeTextValues( rawActs.flatMap((act) => act.encounterNpcIds), ); const availableSceneNpcIds = dedupeTextValues([ ...chapterEncounterNpcIds, ...params.landmark.sceneNpcIds, ]); const availableSceneNpcIdSet = new Set(availableSceneNpcIds); const targetActCount = Math.min( MAX_SCENE_ACT_COUNT, Math.max( MIN_SCENE_ACT_COUNT, rawActs.length > 0 ? rawActs.length : fallbackChapter.acts.length, ), ); const acts = Array.from({ length: targetActCount }, (_unused, index) => { const fallbackAct = buildDefaultSceneActBlueprint({ sceneId: params.landmark.id, sceneName: params.landmark.name, sceneSummary: params.landmark.description, encounterNpcIds: availableSceneNpcIds, backgroundImageSrc: params.fallbackImageSrc, linkedThreadIds: rawActs[index]?.linkedThreadIds ?? fallbackChapter.linkedThreadIds, index, actCount: targetActCount, }); const currentAct = rawActs[index]; const candidateNpcIds = dedupeTextValues(currentAct?.encounterNpcIds ?? []); const encounterNpcIds = availableSceneNpcIdSet.size > 0 ? candidateNpcIds.filter((npcId) => availableSceneNpcIdSet.has(npcId)) : candidateNpcIds; const resolvedEncounterNpcIds = encounterNpcIds.length > 0 ? encounterNpcIds : availableSceneNpcIds.length > 0 ? availableSceneNpcIds.slice(0, 1) : fallbackAct.encounterNpcIds; return { ...fallbackAct, id: currentAct?.id?.trim() || fallbackAct.id, title: currentAct?.title?.trim() || fallbackAct.title, summary: currentAct?.summary?.trim() || fallbackAct.summary, stageCoverage: buildSceneActStageCoverage(index, targetActCount), backgroundPromptText: currentAct?.backgroundPromptText?.trim() || '', backgroundImageSrc: currentAct?.backgroundImageSrc?.trim() || params.fallbackImageSrc || fallbackAct.backgroundImageSrc, encounterNpcIds: resolvedEncounterNpcIds, primaryNpcId: resolvedEncounterNpcIds[0] ?? '', linkedThreadIds: dedupeTextValues(currentAct?.linkedThreadIds ?? []), advanceRule: buildSceneActAdvanceRule(index, targetActCount), actGoal: currentAct?.actGoal?.trim() || fallbackAct.actGoal, transitionHook: currentAct?.transitionHook?.trim() || fallbackAct.transitionHook, } satisfies SceneActBlueprint; }); return { ...fallbackChapter, id: params.chapter?.id?.trim() || fallbackChapter.id, title: params.chapter?.title?.trim() || fallbackChapter.title, summary: params.chapter?.summary?.trim() || fallbackChapter.summary, linkedThreadIds: dedupeTextValues(params.chapter?.linkedThreadIds ?? []), linkedLandmarkIds: dedupeTextValues([ params.landmark.id, ...(params.chapter?.linkedLandmarkIds ?? []), ]), acts, } satisfies SceneChapterBlueprint; } function collectSceneChapterEncounterNpcIds(chapter: SceneChapterBlueprint) { return dedupeTextValues( chapter.acts.flatMap((act) => act.encounterNpcIds), ); } function resolveSceneCompatibilityNpcIds(params: { chapter: SceneChapterBlueprint; currentNpcIds: string[]; }) { const chapterNpcIds = collectSceneChapterEncounterNpcIds(params.chapter); return dedupeTextValues([...chapterNpcIds, ...params.currentNpcIds]); } function resolveSceneCompatibilityImageSrc(params: { chapter: SceneChapterBlueprint; currentImageSrc?: string | null; resolvedImageSrc?: string | null; }) { const currentImageSrc = params.currentImageSrc?.trim() || ''; const resolvedImageSrc = params.resolvedImageSrc?.trim() || ''; const firstActImageSrc = params.chapter.acts[0]?.backgroundImageSrc?.trim() || ''; if (firstActImageSrc && firstActImageSrc !== resolvedImageSrc) { return firstActImageSrc; } return currentImageSrc || undefined; } function resolveSceneChapterBlueprintDraft(params: { profile: CustomWorldProfile; landmark: CustomWorldLandmark; fallbackImageSrc?: string | null; }) { const matchedChapter = params.profile.sceneChapterBlueprints?.find( (entry) => entry.sceneId === params.landmark.id || entry.linkedLandmarkIds.includes(params.landmark.id), ) ?? null; return sanitizeSceneChapterBlueprint({ chapter: matchedChapter, landmark: params.landmark, fallbackImageSrc: params.fallbackImageSrc, }); } function upsertSceneChapterBlueprint( chapters: CustomWorldProfile['sceneChapterBlueprints'], nextChapter: SceneChapterBlueprint, ) { const nextChapters: SceneChapterBlueprint[] = []; let hasReplaced = false; (chapters ?? []).forEach((chapter) => { const isSameScene = chapter.id === nextChapter.id || chapter.sceneId === nextChapter.sceneId || chapter.linkedLandmarkIds.includes(nextChapter.sceneId); if (isSameScene) { if (!hasReplaced) { nextChapters.push(nextChapter); hasReplaced = true; } return; } nextChapters.push(chapter); }); if (!hasReplaced) { nextChapters.push(nextChapter); } return nextChapters; } function normalizeConnectionDirection( value: CustomWorldSceneConnection['relativePosition'], ): CardinalConnectionDirection | null { switch (value) { case 'north': case 'forward': return 'north'; case 'east': case 'right': return 'east'; case 'south': case 'back': return 'south'; case 'west': case 'left': return 'west'; default: return null; } } function buildConnectionSummary( direction: CardinalConnectionDirection, targetName?: string, ) { if (!targetName) { return ''; } return `${CARDINAL_CONNECTION_LABELS[direction]}侧可前往${targetName}`; } function buildDirectionalConnections( connections: CustomWorldSceneConnection[], landmarks: Array>, ) { const directionMap = new Map(); connections.forEach((connection) => { const direction = normalizeConnectionDirection(connection.relativePosition); if (!direction || directionMap.has(direction)) { return; } const targetName = landmarks.find((entry) => entry.id === connection.targetLandmarkId)?.name ?? ''; directionMap.set(direction, { targetLandmarkId: connection.targetLandmarkId, relativePosition: direction, summary: connection.summary || buildConnectionSummary(direction, targetName), }); }); return CARDINAL_CONNECTION_DIRECTIONS.flatMap((direction) => { const connection = directionMap.get(direction); return connection ? [connection] : []; }); } function commaText(value: string[]) { return value.join(', '); } function clampInitialAffinity(value: string, fallback: number) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) { return fallback; } return Math.max(-40, Math.min(90, Math.round(parsed))); } function parseOptionalNumber(value: string) { const trimmed = value.trim(); if (!trimmed) return undefined; const parsed = Number.parseInt(trimmed, 10); return Number.isFinite(parsed) ? parsed : undefined; } function hashText(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) >>> 0; } return hash; } function getItemRarityLabel(rarity: ItemRarity) { return ITEM_RARITY_LABELS[rarity] ?? '普通'; } function getItemRarityCardClass(rarity: ItemRarity) { switch (rarity) { case 'legendary': return 'border-amber-400/45 bg-amber-500/10'; case 'epic': return 'border-fuchsia-400/40 bg-fuchsia-500/10'; case 'rare': return 'border-sky-400/40 bg-sky-500/10'; case 'uncommon': return 'border-emerald-400/35 bg-emerald-500/10'; default: return 'border-white/10 bg-black/20'; } } function inferSkillActionTemplateId(skill: Pick) { const source = `${skill.name} ${skill.summary}`; if (/[跑冲突进闪跃追袭位移]/u.test(source)) { return 'run'; } if (/[受伤护体格挡防御硬扛]/u.test(source)) { return 'hurt'; } if (/[终结死亡献祭同归于尽]/u.test(source)) { return 'die'; } if (/[待机吟唱蓄力观察潜伏准备]/u.test(source)) { return 'idle'; } return 'attack_slash'; } function createRoleRelationDraft(seedLabel: string, index: number): CustomWorldRoleRelation { return { id: createEntryId('relation', seedLabel, Date.now() + index), targetRoleId: '', summary: '', }; } function deriveRelationshipHooksFromRelations(relations: CustomWorldRoleRelation[]) { return relations .map((item) => item.summary.trim()) .filter(Boolean) .slice(0, 8); } function createRoleSkillDraft(seedLabel: string, index: number) { return { id: createEntryId('skill', seedLabel, Date.now() + index), name: `新技能${index + 1}`, summary: '', style: '起手压制', actionPromptText: '', }; } function createRoleInitialItemDraft(seedLabel: string, index: number) { return { id: createEntryId('item', seedLabel, Date.now() + index), name: `新物品${index + 1}`, category: '材料', quantity: 1, rarity: 'rare' as ItemRarity, description: '', tags: [], iconSrc: undefined, }; } function createBackstoryChapterDraft(seedLabel: string, index: number) { return { id: createEntryId('backstory-chapter', seedLabel, Date.now() + index), title: `背景片段${index + 1}`, affinityRequired: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[ Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1) ] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE, teaser: '', content: '', contextSnippet: '', }; } function syncLandmarksWithStoryNpcs( landmarks: CustomWorldLandmark[], storyNpcs: CustomWorldProfile['storyNpcs'], ) { return normalizeCustomWorldLandmarks({ landmarks, storyNpcs, }); } function buildDraftSyncToken(value: unknown) { try { const serialized = JSON.stringify(value); return serialized ?? 'undefined'; } catch { return String(value); } } function useDraft(value: T) { const syncToken = useMemo(() => buildDraftSyncToken(value), [value]); const [draft, setDraft] = useState(value); const lastSyncedTokenRef = useRef(syncToken); useEffect(() => { if (lastSyncedTokenRef.current === syncToken) { return; } lastSyncedTokenRef.current = syncToken; setDraft(value); }, [syncToken, value]); return [draft, setDraft] as const; } function readImageFileAsDataUrl(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result ?? '')); reader.onerror = () => reject(reader.error ?? new Error('读取图片失败。')); reader.readAsDataURL(file); }); } function loadImageDimensionsFromDataUrl(source: string) { return new Promise<{ width: number; height: number }>((resolve, reject) => { const image = new Image(); image.onload = () => { resolve({ width: image.naturalWidth, height: image.naturalHeight, }); }; image.onerror = () => reject(new Error('读取图片尺寸失败。')); image.src = source; }); } function buildCenteredCoverCropRect( width: number, height: number, ): CustomWorldCoverCropRect { const targetRatio = 16 / 9; if (width <= 0 || height <= 0) { return { x: 0, y: 0, width: 1, height: 1 }; } if (width / height >= targetRatio) { const cropHeight = height; const cropWidth = cropHeight * targetRatio; return { x: (width - cropWidth) / 2, y: 0, width: cropWidth, height: cropHeight, }; } const cropWidth = width; const cropHeight = cropWidth / targetRatio; return { x: 0, y: (height - cropHeight) / 2, width: cropWidth, height: cropHeight, }; } function clampCoverCropRect( cropRect: CustomWorldCoverCropRect, imageSize: { width: number; height: number }, ) { const width = Math.max(1, Math.min(imageSize.width, cropRect.width)); const height = Math.max(1, Math.min(imageSize.height, cropRect.height)); const x = Math.max(0, Math.min(imageSize.width - width, cropRect.x)); const y = Math.max(0, Math.min(imageSize.height - height, cropRect.y)); return { x, y, width, height }; } function buildCoverCropPreviewStyle( cropRect: CustomWorldCoverCropRect, imageSize: { width: number; height: number }, ) { if (imageSize.width <= 0 || imageSize.height <= 0) { return {}; } return { left: `${(cropRect.x / imageSize.width) * 100}%`, top: `${(cropRect.y / imageSize.height) * 100}%`, width: `${(cropRect.width / imageSize.width) * 100}%`, height: `${(cropRect.height / imageSize.height) * 100}%`, } satisfies CSSProperties; } function ModalShell({ title, subtitle, onClose, children, panelClassName = 'sm:max-w-2xl', overlayClassName = 'z-[98]', bodyClassName = '', disableClose = false, usePixelFont = false, }: { title: string; subtitle?: string; onClose: () => void; children: ReactNode; panelClassName?: string; overlayClassName?: string; bodyClassName?: string; disableClose?: boolean; usePixelFont?: boolean; }) { const authUi = useAuthUi(); const platformThemeClass = authUi?.platformTheme === 'dark' ? 'platform-theme--dark' : 'platform-theme--light'; return (
{ if (event.target === event.currentTarget) { onClose(); } } } >
event.stopPropagation()} >
{title}
{subtitle ? (
{subtitle}
) : null}
{children}
); } function _PortalModalShell(props: { title: string; subtitle?: string; onClose: () => void; children: ReactNode; panelClassName?: string; overlayClassName?: string; bodyClassName?: string; disableClose?: boolean; usePixelFont?: boolean; }) { if (typeof document === 'undefined') { return null; } return createPortal(, document.body); } function CompactDialogShell({ title, onClose, children, overlayClassName = 'z-[140]', disableClose = false, usePixelFont = false, }: { title: string; onClose: () => void; children: ReactNode; overlayClassName?: string; disableClose?: boolean; usePixelFont?: boolean; }) { const authUi = useAuthUi(); const platformThemeClass = authUi?.platformTheme === 'dark' ? 'platform-theme--dark' : 'platform-theme--light'; return (
{ if (event.target === event.currentTarget) { onClose(); } } } >
event.stopPropagation()} >
{title}
{children}
); } function PortalCompactDialogShell(props: { title: string; onClose: () => void; children: ReactNode; overlayClassName?: string; disableClose?: boolean; usePixelFont?: boolean; }) { if (typeof document === 'undefined') { return null; } return createPortal(, document.body); } function Field({ label, children }: { label: ReactNode; children: ReactNode }) { const hasVisibleChildren = Children.toArray(children).some( (child) => !(typeof child === 'string' && child.trim().length === 0), ); if (!hasVisibleChildren) return null; return ( ); } function LabelWithInfo({ label, info, }: { label: string; info: string; }) { const [open, setOpen] = useState(false); return ( {label} {open ? ( {info} ) : null} ); } function TextInput({ value, onChange, type = 'text', placeholder, }: { value: string | number; onChange: (value: string) => void; type?: 'text' | 'number'; placeholder?: string; }) { return ( onChange(event.target.value)} className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35" /> ); } function TextArea({ value, onChange, rows = 4, placeholder, }: { value: string; onChange: (value: string) => void; rows?: number; placeholder?: string; }) { return (