101f595bac
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
251 lines
8.2 KiB
TypeScript
251 lines
8.2 KiB
TypeScript
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: false,
|
||
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: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||
underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')),
|
||
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||
blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')),
|
||
}),
|
||
});
|
||
|
||
const handleSave = () => {
|
||
if (!editor || editor.isDestroyed) return;
|
||
let raw = '';
|
||
try {
|
||
raw = editor.getHTML();
|
||
} catch {
|
||
return;
|
||
}
|
||
onSave(normalizeSceneDescriptionHtml(raw));
|
||
};
|
||
|
||
if (!editor || editor.isDestroyed) {
|
||
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>
|
||
</>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
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 ?? false}
|
||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||
>
|
||
<strong>B</strong>
|
||
</ToolButton>
|
||
<ToolButton
|
||
title={t('scene.descriptionItalic')}
|
||
active={toolbarState?.italic ?? false}
|
||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||
>
|
||
<em>I</em>
|
||
</ToolButton>
|
||
<ToolButton
|
||
title={t('scene.descriptionUnderline')}
|
||
active={toolbarState?.underline ?? false}
|
||
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 ?? false}
|
||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||
>
|
||
H2
|
||
</ToolButton>
|
||
<ToolButton
|
||
title={t('scene.descriptionHeading3')}
|
||
active={toolbarState?.h3 ?? false}
|
||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||
>
|
||
H3
|
||
</ToolButton>
|
||
<ToolButton
|
||
title={t('scene.descriptionQuote')}
|
||
active={toolbarState?.blockquote ?? false}
|
||
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 ?? false}
|
||
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 ?? false}
|
||
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,
|
||
);
|
||
}
|