From 195d4be086a0f83fcad61fcd08963e30ce270e0d Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 15 Jul 2026 19:04:02 +0800 Subject: [PATCH] fix(editor): polish project picker and scene description UX Validate unique campaign names, add search, flip menus at scroll end, drop TipTap links, and scroll long description previews. Co-authored-by: Cursor --- app/renderer/editor/EditorApp.module.css | 15 ++++++- app/renderer/editor/EditorApp.tsx | 45 ++++++++++++++++--- app/renderer/editor/SceneDescriptionModal.tsx | 29 +----------- app/renderer/editor/i18n/editorMessages.ts | 4 ++ 4 files changed, 56 insertions(+), 37 deletions(-) diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index d79f982..3ea4615 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -259,6 +259,17 @@ font: inherit; } +.fileMenuItemDanger { + text-align: left; + padding: 10px; + border-radius: var(--radius-sm); + border: none; + background: transparent; + color: var(--color-danger); + cursor: pointer; + font: inherit; +} + .fileMenuSubHost { position: relative; } @@ -688,7 +699,8 @@ .descriptionPreview { max-height: 4.6em; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; padding: 8px 10px; border-radius: var(--radius-md); border: 1px solid var(--stroke); @@ -697,7 +709,6 @@ font-size: var(--text-xs); line-height: 1.45; word-break: break-word; - pointer-events: none; } .descriptionPreview :global(p), diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index ff43266..4e19d37 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -1893,11 +1893,13 @@ function ProjectPicker({ }: ProjectPickerProps) { const { t, locale } = useEditorI18n(); const [name, setName] = useState(() => t('picker.defaultName')); + const [projectQuery, setProjectQuery] = useState(''); const [rowMenuFor, setRowMenuFor] = useState(null); const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null); const [pendingDelete, setPendingDelete] = useState<{ id: ProjectId; name: string } | null>(null); const [deleteSubmitting, setDeleteSubmitting] = useState(false); const [deleteError, setDeleteError] = useState(null); + const projectListScrollRef = useRef(null); useEffect(() => { if (!rowMenuFor) return; @@ -1912,24 +1914,41 @@ function ProjectPicker({ return () => window.removeEventListener('mousedown', onDown); }, [rowMenuFor]); + const trimmedName = name.trim(); + const nameOk = trimmedName.length >= 3; + const nameDup = projects.some((p) => normalizeName(p.name) === normalizeName(trimmedName)); + const canCreate = licenseActive && nameOk && !nameDup; + + const filteredProjects = useMemo(() => { + const q = projectQuery.trim().toLowerCase(); + if (!q) return projects; + return projects.filter((p) => p.name.toLowerCase().includes(q)); + }, [projectQuery, projects]); + return (
{t('picker.title')}
+ {!nameOk ?
{t('rename.projectMin')}
: null} + {nameOk && nameDup ?
{t('rename.projectDup')}
: null}
+
+ +
+
{t('picker.existing')}
{!licenseActive && projects.length > 0 ? ( <> @@ -1937,9 +1956,9 @@ function ProjectPicker({
) : null} -
+
- {projects.map((p) => { + {filteredProjects.map((p) => { const isOpening = openingProjectId === p.id; const openDisabled = !licenseActive || openingProjectId !== null; return ( @@ -2000,8 +2019,17 @@ function ProjectPicker({ if (!licenseActive || openingProjectId !== null) return; const r = e.currentTarget.getBoundingClientRect(); const menuW = 220; + const menuH = 96; + const gap = 8; const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 8)); - setRowMenuPos({ left, top: r.bottom + 8 }); + const scrollEl = projectListScrollRef.current; + const scrollAtEnd = + !!scrollEl && + scrollEl.scrollHeight > scrollEl.clientHeight && + scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 2; + const openUp = scrollAtEnd || r.bottom + gap + menuH > window.innerHeight - 8; + const top = openUp ? Math.max(8, r.top - menuH - gap) : r.bottom + gap; + setRowMenuPos({ left, top }); setRowMenuFor((cur) => (cur === p.id ? null : p.id)); }} > @@ -2011,6 +2039,9 @@ function ProjectPicker({ ); })} {projects.length === 0 ?
{t('picker.empty')}
: null} + {projects.length > 0 && filteredProjects.length === 0 ? ( +
{t('picker.searchEmpty')}
+ ) : null}
{rowMenuFor && rowMenuPos @@ -2038,7 +2069,7 @@ function ProjectPicker({
diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index f4c2054..603c622 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -306,6 +306,8 @@ export const EDITOR_MESSAGES: Record> = { 'picker.title': 'Проекты', 'picker.newPlaceholder': 'Название нового проекта…', 'picker.create': 'Создать проект', + 'picker.search': 'Поиск кампаний…', + 'picker.searchEmpty': 'Ничего не найдено.', 'picker.existing': 'СУЩЕСТВУЮЩИЕ', 'picker.lockedHint': 'Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения.', @@ -709,6 +711,8 @@ export const EDITOR_MESSAGES: Record> = { 'picker.title': 'Projects', 'picker.newPlaceholder': 'New project name…', 'picker.create': 'Create project', + 'picker.search': 'Search campaigns…', + 'picker.searchEmpty': 'No matches.', 'picker.existing': 'EXISTING', 'picker.lockedHint': 'Opening and creating projects require an active license. The list still shows files in the app folder.',