Files
DndGamePlayer/app/renderer/npcs/NpcDescriptionField.tsx
T
Ivan Fontosh 04c75cd725 fix(npcs): stop TipTap crash black screen in secondary windows
Guard TipTap getHTML under StrictMode, wrap secondary window roots in WindowErrorBoundary, and add stability regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 09:10:47 +08:00

164 lines
5.0 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 { 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>
);
}