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:
@@ -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<ProjectId | null>(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<string | null>(null);
|
||||
const projectListScrollRef = useRef<HTMLDivElement | null>(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 (
|
||||
<div className={styles.projectPicker}>
|
||||
<div className={styles.projectPickerTitle}>{t('picker.title')}</div>
|
||||
<div className={styles.projectPickerForm}>
|
||||
<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
|
||||
variant="primary"
|
||||
disabled={!licenseActive}
|
||||
disabled={!canCreate}
|
||||
title={!licenseActive ? t('top.afterLicense') : undefined}
|
||||
onClick={() => {
|
||||
if (!licenseActive) return;
|
||||
void onCreate(name);
|
||||
if (!canCreate) return;
|
||||
void onCreate(trimmedName);
|
||||
}}
|
||||
>
|
||||
{t('picker.create')}
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
{!licenseActive && projects.length > 0 ? (
|
||||
<>
|
||||
@@ -1937,9 +1956,9 @@ function ProjectPicker({
|
||||
<div className={styles.spacer6} />
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.projectListScroll}>
|
||||
<div className={styles.projectListScroll} ref={projectListScrollRef}>
|
||||
<div className={styles.projectList}>
|
||||
{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 ? <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>
|
||||
{rowMenuFor && rowMenuPos
|
||||
@@ -2038,7 +2069,7 @@ function ProjectPicker({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.fileMenuItem}
|
||||
className={styles.fileMenuItemDanger}
|
||||
onClick={() => {
|
||||
const id = rowMenuFor;
|
||||
const proj = projects.find((x) => x.id === id);
|
||||
|
||||
Reference in New Issue
Block a user