04c75cd725
Guard TipTap getHTML under StrictMode, wrap secondary window roots in WindowErrorBoundary, and add stability regression tests. Co-authored-by: Cursor <cursoragent@cursor.com>
164 lines
5.0 KiB
TypeScript
164 lines
5.0 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 { normalizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||
import modalStyles from '../editor/SceneDescriptionModal.module.css';
|
||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||
|
||
import styles from './NpcsEditorApp.module.css';
|
||
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||
|
||
type NpcDescriptionFieldProps = {
|
||
html: string;
|
||
onCommit: (html: string) => void;
|
||
};
|
||
|
||
function ToolButton({
|
||
active = false,
|
||
title,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
active?: boolean;
|
||
title: string;
|
||
onClick: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
title={title}
|
||
aria-label={title}
|
||
aria-pressed={active}
|
||
className={[modalStyles.toolBtn, active ? modalStyles.toolBtnActive : ''].filter(Boolean).join(' ')}
|
||
onClick={onClick}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps) {
|
||
const { t } = useEditorI18n();
|
||
|
||
const extensions = useMemo(
|
||
() => [
|
||
StarterKit.configure({
|
||
heading: { levels: [2, 3] },
|
||
codeBlock: false,
|
||
link: false,
|
||
}),
|
||
Placeholder.configure({
|
||
placeholder: t('npcs.descriptionPlaceholder'),
|
||
}),
|
||
],
|
||
[t],
|
||
);
|
||
|
||
const editor = useEditor({
|
||
extensions,
|
||
content: html || '',
|
||
// StrictMode + true даёт destroy/recreate с null schema → падение getHTML (чёрный экран окна НПС).
|
||
immediatelyRender: false,
|
||
shouldRerenderOnTransaction: true,
|
||
editorProps: {
|
||
attributes: {
|
||
class: [modalStyles.prose, 'tiptap'].join(' '),
|
||
'aria-label': t('npcs.description'),
|
||
},
|
||
},
|
||
onBlur: ({ editor: ed }) => {
|
||
const raw = readTipTapHtmlSafe(ed);
|
||
if (raw == null) return;
|
||
onCommit(normalizeSceneDescriptionHtml(raw));
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (!editor || editor.isDestroyed) return;
|
||
const raw = readTipTapHtmlSafe(editor);
|
||
if (raw == null) return;
|
||
const current = normalizeSceneDescriptionHtml(raw);
|
||
const next = normalizeSceneDescriptionHtml(html);
|
||
if (current !== next) {
|
||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||
}
|
||
}, [editor, html]);
|
||
|
||
const toolbarState = useEditorState({
|
||
editor,
|
||
selector: ({ editor: ed }) => ({
|
||
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||
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 })),
|
||
}),
|
||
});
|
||
|
||
if (!editor || editor.isDestroyed) return null;
|
||
|
||
return (
|
||
<div className={styles.descShell}>
|
||
<div className={modalStyles.toolbar}>
|
||
<div className={modalStyles.toolbarGroup}>
|
||
<ToolButton
|
||
active={toolbarState?.bold ?? false}
|
||
title={t('scene.descriptionBold')}
|
||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||
>
|
||
B
|
||
</ToolButton>
|
||
<ToolButton
|
||
active={toolbarState?.italic ?? false}
|
||
title={t('scene.descriptionItalic')}
|
||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||
>
|
||
I
|
||
</ToolButton>
|
||
</div>
|
||
<div className={modalStyles.toolbarSep} />
|
||
<div className={modalStyles.toolbarGroup}>
|
||
<ToolButton
|
||
active={toolbarState?.h2 ?? false}
|
||
title={t('scene.descriptionHeading2')}
|
||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||
>
|
||
H2
|
||
</ToolButton>
|
||
<ToolButton
|
||
active={toolbarState?.h3 ?? false}
|
||
title={t('scene.descriptionHeading3')}
|
||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||
>
|
||
H3
|
||
</ToolButton>
|
||
</div>
|
||
<div className={modalStyles.toolbarSep} />
|
||
<div className={modalStyles.toolbarGroup}>
|
||
<ToolButton
|
||
active={toolbarState?.bulletList ?? false}
|
||
title={t('scene.descriptionBulletList')}
|
||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||
>
|
||
•
|
||
</ToolButton>
|
||
<ToolButton
|
||
active={toolbarState?.orderedList ?? false}
|
||
title={t('scene.descriptionOrderedList')}
|
||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||
>
|
||
1.
|
||
</ToolButton>
|
||
</div>
|
||
</div>
|
||
<div className={styles.descContent}>
|
||
<EditorContent editor={editor} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|