CI / Build & Quality Checks (push) Successful in 1m36s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m24s
CustomEditor passed one module-level initialValue array to every <Slate>. slate-react keys its node→path weak maps by node identity, so mounting the thread composer re-mapped the shared nodes to the new editor and the main composer threw "Unable to find the path for Slate node" on its next render, taking the whole client to the error boundary. Anything that had already edited the main editor (typing, a restored draft) replaced its nodes and masked the bug, which is why it was intermittent. Reproduced with Playwright (fresh login → room → click "N replies"): crashed every time; typing one character first avoided it. Fixed by creating the initial value per instance (useState). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
174 lines
5.0 KiB
TypeScript
174 lines
5.0 KiB
TypeScript
import React, {
|
|
ClipboardEventHandler,
|
|
KeyboardEventHandler,
|
|
ReactNode,
|
|
forwardRef,
|
|
useCallback,
|
|
useState,
|
|
} from 'react';
|
|
import { Box, Scroll, Text } from 'folds';
|
|
import { Descendant, Editor, createEditor } from 'slate';
|
|
import {
|
|
Slate,
|
|
Editable,
|
|
withReact,
|
|
RenderLeafProps,
|
|
RenderElementProps,
|
|
RenderPlaceholderProps,
|
|
} from 'slate-react';
|
|
import { withHistory } from 'slate-history';
|
|
import { BlockType } from './types';
|
|
import { RenderElement, RenderLeaf } from './Elements';
|
|
import { CustomElement } from './slate';
|
|
import * as css from './Editor.css';
|
|
import { toggleKeyboardShortcut } from './keyboard';
|
|
|
|
// One FRESH value per editor instance. slate-react keys its node→path weak
|
|
// maps by node object identity, so a module-level constant shared by every
|
|
// <Slate> (main composer + thread composer + message editor) makes the second
|
|
// mount hijack the first editor's nodes and the first editor throws "Unable to
|
|
// find the path for Slate node" on its next render — the app-wide crash when
|
|
// opening a thread on a pristine composer (Gitea #165 / #184).
|
|
const createInitialValue = (): CustomElement[] => [
|
|
{
|
|
type: BlockType.Paragraph,
|
|
children: [{ text: '' }],
|
|
},
|
|
];
|
|
|
|
const withInline = (editor: Editor): Editor => {
|
|
const { isInline } = editor;
|
|
|
|
editor.isInline = (element) =>
|
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
|
|
element.type,
|
|
) || isInline(element);
|
|
|
|
return editor;
|
|
};
|
|
|
|
const withVoid = (editor: Editor): Editor => {
|
|
const { isVoid } = editor;
|
|
|
|
editor.isVoid = (element) =>
|
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
|
|
isVoid(element);
|
|
|
|
return editor;
|
|
};
|
|
|
|
export const useEditor = (): Editor => {
|
|
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
|
|
return editor;
|
|
};
|
|
|
|
export type EditorChangeHandler = (value: Descendant[]) => void;
|
|
type CustomEditorProps = {
|
|
editableName?: string;
|
|
top?: ReactNode;
|
|
bottom?: ReactNode;
|
|
before?: ReactNode;
|
|
after?: ReactNode;
|
|
maxHeight?: string;
|
|
editor: Editor;
|
|
placeholder?: string;
|
|
/** Explicit accessible name for the textbox; falls back to the placeholder. */
|
|
ariaLabel?: string;
|
|
onKeyDown?: KeyboardEventHandler;
|
|
onKeyUp?: KeyboardEventHandler;
|
|
onChange?: EditorChangeHandler;
|
|
onPaste?: ClipboardEventHandler;
|
|
};
|
|
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|
(
|
|
{
|
|
editableName,
|
|
top,
|
|
bottom,
|
|
before,
|
|
after,
|
|
maxHeight = '50vh',
|
|
editor,
|
|
placeholder,
|
|
ariaLabel,
|
|
onKeyDown,
|
|
onKeyUp,
|
|
onChange,
|
|
onPaste,
|
|
},
|
|
ref,
|
|
) => {
|
|
const [initialValue] = useState(createInitialValue);
|
|
const renderElement = useCallback(
|
|
(props: RenderElementProps) => <RenderElement {...props} />,
|
|
[],
|
|
);
|
|
|
|
const renderLeaf = useCallback((props: RenderLeafProps) => <RenderLeaf {...props} />, []);
|
|
|
|
const handleKeydown: KeyboardEventHandler = useCallback(
|
|
(evt) => {
|
|
onKeyDown?.(evt);
|
|
const shortcutToggled = toggleKeyboardShortcut(editor, evt);
|
|
if (shortcutToggled) evt.preventDefault();
|
|
},
|
|
[editor, onKeyDown],
|
|
);
|
|
|
|
const renderPlaceholder = useCallback(
|
|
({ attributes, children }: RenderPlaceholderProps) => (
|
|
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
|
{/* Inner component to style the actual text position and appearance */}
|
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
|
{children}
|
|
</Text>
|
|
</span>
|
|
),
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div className={css.Editor} ref={ref}>
|
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
|
{top}
|
|
<Box className={css.EditorInputRow} alignItems="Start">
|
|
{before && (
|
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
|
{before}
|
|
</Box>
|
|
)}
|
|
<Scroll
|
|
className={css.EditorTextareaScroll}
|
|
variant="SurfaceVariant"
|
|
style={{ maxHeight }}
|
|
size="300"
|
|
visibility="Hover"
|
|
hideTrack
|
|
>
|
|
<Editable
|
|
data-editable-name={editableName}
|
|
className={css.EditorTextarea}
|
|
placeholder={placeholder}
|
|
aria-label={ariaLabel ?? placeholder ?? 'Message input'}
|
|
aria-multiline="true"
|
|
renderPlaceholder={renderPlaceholder}
|
|
renderElement={renderElement}
|
|
renderLeaf={renderLeaf}
|
|
onKeyDown={handleKeydown}
|
|
onKeyUp={onKeyUp}
|
|
onPaste={onPaste}
|
|
/>
|
|
</Scroll>
|
|
{after && (
|
|
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
|
{after}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
{bottom}
|
|
</Slate>
|
|
</div>
|
|
);
|
|
},
|
|
);
|