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 <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-15 19:04:02 +08:00
parent 8d73f7744e
commit 195d4be086
4 changed files with 56 additions and 37 deletions
+13 -2
View File
@@ -259,6 +259,17 @@
font: inherit; 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 { .fileMenuSubHost {
position: relative; position: relative;
} }
@@ -688,7 +699,8 @@
.descriptionPreview { .descriptionPreview {
max-height: 4.6em; max-height: 4.6em;
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
padding: 8px 10px; padding: 8px 10px;
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--stroke); border: 1px solid var(--stroke);
@@ -697,7 +709,6 @@
font-size: var(--text-xs); font-size: var(--text-xs);
line-height: 1.45; line-height: 1.45;
word-break: break-word; word-break: break-word;
pointer-events: none;
} }
.descriptionPreview :global(p), .descriptionPreview :global(p),
+38 -7
View File
@@ -1893,11 +1893,13 @@ function ProjectPicker({
}: ProjectPickerProps) { }: ProjectPickerProps) {
const { t, locale } = useEditorI18n(); const { t, locale } = useEditorI18n();
const [name, setName] = useState(() => t('picker.defaultName')); const [name, setName] = useState(() => t('picker.defaultName'));
const [projectQuery, setProjectQuery] = useState('');
const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null); const [rowMenuFor, setRowMenuFor] = useState<ProjectId | null>(null);
const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null); const [rowMenuPos, setRowMenuPos] = useState<{ left: number; top: number } | null>(null);
const [pendingDelete, setPendingDelete] = useState<{ id: ProjectId; name: string } | null>(null); const [pendingDelete, setPendingDelete] = useState<{ id: ProjectId; name: string } | null>(null);
const [deleteSubmitting, setDeleteSubmitting] = useState(false); const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const projectListScrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (!rowMenuFor) return; if (!rowMenuFor) return;
@@ -1912,24 +1914,41 @@ function ProjectPicker({
return () => window.removeEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown);
}, [rowMenuFor]); }, [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 ( return (
<div className={styles.projectPicker}> <div className={styles.projectPicker}>
<div className={styles.projectPickerTitle}>{t('picker.title')}</div> <div className={styles.projectPickerTitle}>{t('picker.title')}</div>
<div className={styles.projectPickerForm}> <div className={styles.projectPickerForm}>
<Input value={name} onChange={setName} placeholder={t('picker.newPlaceholder')} /> <Input value={name} onChange={setName} placeholder={t('picker.newPlaceholder')} />
{!nameOk ? <div className={styles.fieldError}>{t('rename.projectMin')}</div> : null}
{nameOk && nameDup ? <div className={styles.fieldError}>{t('rename.projectDup')}</div> : null}
<Button <Button
variant="primary" variant="primary"
disabled={!licenseActive} disabled={!canCreate}
title={!licenseActive ? t('top.afterLicense') : undefined} title={!licenseActive ? t('top.afterLicense') : undefined}
onClick={() => { onClick={() => {
if (!licenseActive) return; if (!canCreate) return;
void onCreate(name); void onCreate(trimmedName);
}} }}
> >
{t('picker.create')} {t('picker.create')}
</Button> </Button>
</div> </div>
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<div className={styles.gridTools}>
<Input value={projectQuery} onChange={setProjectQuery} placeholder={t('picker.search')} />
</div>
<div className={styles.spacer6} />
<div className={styles.sectionLabel}>{t('picker.existing')}</div> <div className={styles.sectionLabel}>{t('picker.existing')}</div>
{!licenseActive && projects.length > 0 ? ( {!licenseActive && projects.length > 0 ? (
<> <>
@@ -1937,9 +1956,9 @@ function ProjectPicker({
<div className={styles.spacer6} /> <div className={styles.spacer6} />
</> </>
) : null} ) : null}
<div className={styles.projectListScroll}> <div className={styles.projectListScroll} ref={projectListScrollRef}>
<div className={styles.projectList}> <div className={styles.projectList}>
{projects.map((p) => { {filteredProjects.map((p) => {
const isOpening = openingProjectId === p.id; const isOpening = openingProjectId === p.id;
const openDisabled = !licenseActive || openingProjectId !== null; const openDisabled = !licenseActive || openingProjectId !== null;
return ( return (
@@ -2000,8 +2019,17 @@ function ProjectPicker({
if (!licenseActive || openingProjectId !== null) return; if (!licenseActive || openingProjectId !== null) return;
const r = e.currentTarget.getBoundingClientRect(); const r = e.currentTarget.getBoundingClientRect();
const menuW = 220; const menuW = 220;
const menuH = 96;
const gap = 8;
const left = Math.max(8, Math.min(r.right - menuW, window.innerWidth - menuW - 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)); setRowMenuFor((cur) => (cur === p.id ? null : p.id));
}} }}
> >
@@ -2011,6 +2039,9 @@ function ProjectPicker({
); );
})} })}
{projects.length === 0 ? <div className={styles.muted}>{t('picker.empty')}</div> : null} {projects.length === 0 ? <div className={styles.muted}>{t('picker.empty')}</div> : null}
{projects.length > 0 && filteredProjects.length === 0 ? (
<div className={styles.muted}>{t('picker.searchEmpty')}</div>
) : null}
</div> </div>
</div> </div>
{rowMenuFor && rowMenuPos {rowMenuFor && rowMenuPos
@@ -2038,7 +2069,7 @@ function ProjectPicker({
<button <button
type="button" type="button"
role="menuitem" role="menuitem"
className={styles.fileMenuItem} className={styles.fileMenuItemDanger}
onClick={() => { onClick={() => {
const id = rowMenuFor; const id = rowMenuFor;
const proj = projects.find((x) => x.id === id); const proj = projects.find((x) => x.id === id);
+1 -28
View File
@@ -61,14 +61,7 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
StarterKit.configure({ StarterKit.configure({
heading: { levels: [2, 3] }, heading: { levels: [2, 3] },
codeBlock: false, codeBlock: false,
link: { link: false,
openOnClick: false,
autolink: true,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
},
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: t('scene.descriptionPlaceholder'), placeholder: t('scene.descriptionPlaceholder'),
@@ -109,7 +102,6 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
h2: ed.isActive('heading', { level: 2 }), h2: ed.isActive('heading', { level: 2 }),
h3: ed.isActive('heading', { level: 3 }), h3: ed.isActive('heading', { level: 3 }),
blockquote: ed.isActive('blockquote'), blockquote: ed.isActive('blockquote'),
canLink: ed.isEditable,
}), }),
}); });
@@ -117,18 +109,6 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
onSave(normalizeSceneDescriptionHtml(editor.getHTML())); onSave(normalizeSceneDescriptionHtml(editor.getHTML()));
}; };
const setLink = () => {
const prev = editor.getAttributes('link').href as string | undefined;
const next = window.prompt(t('scene.descriptionLinkPrompt'), prev ?? 'https://');
if (next === null) return;
const trimmed = next.trim();
if (trimmed === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run();
};
return createPortal( return createPortal(
<> <>
<button <button
@@ -215,13 +195,6 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
> >
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" /> <ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</ToolButton> </ToolButton>
<ToolButton
title={t('scene.descriptionLink')}
disabled={!toolbarState.canLink}
onClick={setLink}
>
<ToolbarIcon path="M3.9 12a5 5 0 0 1 5-5h4v2h-4a3 3 0 1 0 0 6h4v2h-4a5 5 0 0 1-5-5zm7-1h6v2h-6v-2zm5-4h-4v2h4a3 3 0 1 1 0 6h-4v2h4a5 5 0 0 0 0-10z" />
</ToolButton>
</div> </div>
</div> </div>
<div className={modalStyles.editorContent}> <div className={modalStyles.editorContent}>
@@ -306,6 +306,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'picker.title': 'Проекты', 'picker.title': 'Проекты',
'picker.newPlaceholder': 'Название нового проекта…', 'picker.newPlaceholder': 'Название нового проекта…',
'picker.create': 'Создать проект', 'picker.create': 'Создать проект',
'picker.search': 'Поиск кампаний…',
'picker.searchEmpty': 'Ничего не найдено.',
'picker.existing': 'СУЩЕСТВУЮЩИЕ', 'picker.existing': 'СУЩЕСТВУЮЩИЕ',
'picker.lockedHint': 'picker.lockedHint':
'Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения.', 'Открытие и создание — после активации лицензии. Список показывает файлы в папке приложения.',
@@ -709,6 +711,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'picker.title': 'Projects', 'picker.title': 'Projects',
'picker.newPlaceholder': 'New project name…', 'picker.newPlaceholder': 'New project name…',
'picker.create': 'Create project', 'picker.create': 'Create project',
'picker.search': 'Search campaigns…',
'picker.searchEmpty': 'No matches.',
'picker.existing': 'EXISTING', 'picker.existing': 'EXISTING',
'picker.lockedHint': 'picker.lockedHint':
'Opening and creating projects require an active license. The list still shows files in the app folder.', 'Opening and creating projects require an active license. The list still shows files in the app folder.',