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:
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user