Files
DndGamePlayer/app/renderer/editor/SceneDescriptionModal.tsx
T
Ivan Fontosh 195d4be086 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>
2026-07-15 19:04:02 +08:00

216 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import React, { useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { normalizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import modalStyles from './SceneDescriptionModal.module.css';
type SceneDescriptionModalProps = {
initialHtml: string;
onClose: () => void;
onSave: (html: string) => void;
};
function ToolbarIcon({ path, size = 14 }: { path: string; size?: number }) {
return (
<svg className={modalStyles.toolIcon} viewBox="0 0 24 24" width={size} height={size} aria-hidden>
<path fill="currentColor" d={path} />
</svg>
);
}
function ToolButton({
active = false,
disabled = false,
title,
onClick,
children,
}: {
active?: boolean;
disabled?: boolean;
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
aria-pressed={active}
disabled={disabled}
className={[modalStyles.toolBtn, active ? modalStyles.toolBtnActive : ''].filter(Boolean).join(' ')}
onClick={onClick}
>
{children}
</button>
);
}
export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDescriptionModalProps) {
const { t } = useEditorI18n();
const extensions = useMemo(
() => [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
link: false,
}),
Placeholder.configure({
placeholder: t('scene.descriptionPlaceholder'),
}),
],
[t],
);
const editor = useEditor({
extensions,
content: initialHtml || '',
immediatelyRender: true,
shouldRerenderOnTransaction: true,
editorProps: {
attributes: {
class: [modalStyles.prose, 'tiptap'].join(' '),
'aria-label': t('scene.descriptionModalTitle'),
},
},
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const toolbarState = useEditorState({
editor,
selector: ({ editor: ed }) => ({
bold: ed.isActive('bold'),
italic: ed.isActive('italic'),
underline: ed.isActive('underline'),
bulletList: ed.isActive('bulletList'),
orderedList: ed.isActive('orderedList'),
h2: ed.isActive('heading', { level: 2 }),
h3: ed.isActive('heading', { level: 3 }),
blockquote: ed.isActive('blockquote'),
}),
});
const handleSave = () => {
onSave(normalizeSceneDescriptionHtml(editor.getHTML()));
};
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={modalStyles.editorShell}>
<div className={modalStyles.toolbar} role="toolbar" aria-label={t('scene.descriptionToolbar')}>
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionBold')}
active={toolbarState.bold}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<strong>B</strong>
</ToolButton>
<ToolButton
title={t('scene.descriptionItalic')}
active={toolbarState.italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<em>I</em>
</ToolButton>
<ToolButton
title={t('scene.descriptionUnderline')}
active={toolbarState.underline}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<span style={{ textDecoration: 'underline' }}>U</span>
</ToolButton>
</div>
<div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionHeading2')}
active={toolbarState.h2}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2
</ToolButton>
<ToolButton
title={t('scene.descriptionHeading3')}
active={toolbarState.h3}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
H3
</ToolButton>
<ToolButton
title={t('scene.descriptionQuote')}
active={toolbarState.blockquote}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</ToolButton>
</div>
<div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionBulletList')}
active={toolbarState.bulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" />
</ToolButton>
<ToolButton
title={t('scene.descriptionOrderedList')}
active={toolbarState.orderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</ToolButton>
</div>
</div>
<div className={modalStyles.editorContent}>
<EditorContent editor={editor} />
</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="primary" onClick={handleSave}>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}