mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-21 05:54:10 +08:00
MM-69912: let plugins bring their own editor extensions and read structured content (#37678)
* MM-69912: let plugins bring their own editor extensions and read structured content * lint * Claude PR feedback * Claude AI review second pass * gate autosave on load failure and harden json-mode parse path * fix stale hasContentError handle and scope the error latch to load * shorten required comment --------- Co-authored-by: Nevyana Angelova <nevyangelova@Nevy-Macbook-16-2025.local> Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
co-authored by
Nevyana Angelova
Mattermost Build
parent
d4d216e93e
commit
b9cea25748
+454
@@ -0,0 +1,454 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Node} from '@tiptap/core';
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext} from 'tests/react_testing_utils';
|
||||
|
||||
const mockCapturedConfig: {current: any} = {current: null};
|
||||
|
||||
// Set to make the useEditor mock emit a contentError during construction,
|
||||
// matching Tiptap's render-phase emit.
|
||||
const mockConstructorError: {current: Error | null} = {current: null};
|
||||
|
||||
jest.mock('@tiptap/react', () => {
|
||||
const ReactMock = require('react') as typeof import('react');
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditor: (config: any) => {
|
||||
mockCapturedConfig.current = config;
|
||||
const base: any = {
|
||||
isDestroyed: false,
|
||||
isEmpty: true,
|
||||
commands: {
|
||||
clearContent: () => undefined,
|
||||
focus: () => undefined,
|
||||
blur: () => undefined,
|
||||
insertContent: () => undefined,
|
||||
},
|
||||
setEditable: () => undefined,
|
||||
getJSON: () => ({type: 'doc', content: [{type: 'paragraph', content: [{type: 'text', text: 'hi'}]}]}),
|
||||
view: {dom: globalThis.document.createElement('div')},
|
||||
};
|
||||
|
||||
// Mirrors the real library: getMarkdown is attached by the Markdown
|
||||
// extension's onBeforeCreate, not by the contentType option.
|
||||
const hasMarkdownExt = (config?.extensions ?? []).some((e: any) => (e.name || e.config?.name) === 'markdown');
|
||||
if (hasMarkdownExt) {
|
||||
base.getMarkdown = () => 'hi';
|
||||
}
|
||||
|
||||
// Tiptap emits contentError synchronously inside the Editor
|
||||
// constructor, i.e. during render, and constructs only once per
|
||||
// mount. Consume the error so re-renders don't re-emit.
|
||||
if (mockConstructorError.current) {
|
||||
const error = mockConstructorError.current;
|
||||
mockConstructorError.current = null;
|
||||
config?.onContentError?.({error, editor: base, disableCollaboration: () => undefined});
|
||||
}
|
||||
return base;
|
||||
},
|
||||
EditorContent: () => ReactMock.createElement('div', {'data-testid': 'editor-content'}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./wysiwyg_suggestion_list', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import WysiwygEditor from './wysiwyg_editor';
|
||||
|
||||
const baseProps = {
|
||||
value: '',
|
||||
onChange: jest.fn(),
|
||||
onSubmit: jest.fn(),
|
||||
channelId: 'c1',
|
||||
};
|
||||
|
||||
const extensionNames = (): string[] => (mockCapturedConfig.current?.extensions ?? []).map((e: any) => e.name || e.config?.name);
|
||||
|
||||
describe('WysiwygEditor', () => {
|
||||
beforeEach(() => {
|
||||
mockCapturedConfig.current = null;
|
||||
mockConstructorError.current = null;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('markdown mode (default) registers the Markdown extension', () => {
|
||||
renderWithContext(<WysiwygEditor {...baseProps}/>);
|
||||
|
||||
expect(extensionNames()).toContain('markdown');
|
||||
expect(mockCapturedConfig.current?.contentType).toBe('markdown');
|
||||
});
|
||||
|
||||
test('json mode omits the Markdown extension and drops the markdown contentType', () => {
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(extensionNames()).not.toContain('markdown');
|
||||
expect(mockCapturedConfig.current?.contentType).toBeUndefined();
|
||||
});
|
||||
|
||||
test('extensions prop is appended to the built-in set at mount', () => {
|
||||
const CustomNode = Node.create({name: 'customNode', group: 'block'});
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
extensions={[CustomNode]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const names = extensionNames();
|
||||
expect(names).toContain('table'); // built-ins still present
|
||||
|
||||
// Position matters: consumer extensions must come last so they can
|
||||
// override built-in nodes of the same name.
|
||||
expect(names.indexOf('customNode')).toBe(names.length - 1);
|
||||
expect(names.indexOf('customNode')).toBeGreaterThan(names.indexOf('markdown'));
|
||||
});
|
||||
|
||||
test('extensions prop is appended in json mode too, where Markdown is absent', () => {
|
||||
const CustomNode = Node.create({name: 'customNode', group: 'block'});
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
contentType='json'
|
||||
extensions={[CustomNode]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const names = extensionNames();
|
||||
expect(names).not.toContain('markdown');
|
||||
expect(names.indexOf('customNode')).toBe(names.length - 1);
|
||||
});
|
||||
|
||||
test('onChange emits JSON in json mode', () => {
|
||||
jest.useFakeTimers();
|
||||
const onChange = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
|
||||
mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({type: 'doc', content: []}), getMarkdown: () => ''} as any});
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(JSON.stringify({type: 'doc', content: []}));
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('onChange emits markdown in markdown mode', () => {
|
||||
jest.useFakeTimers();
|
||||
const onChange = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({}), getMarkdown: () => 'hello'} as any});
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('hello');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('json mode parses a JSON string value into an object for initial content', () => {
|
||||
const doc = {type: 'doc', content: [{type: 'paragraph'}]};
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value={JSON.stringify(doc)}
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockCapturedConfig.current?.content).toEqual(doc);
|
||||
});
|
||||
|
||||
test('json mode falls back to an empty doc when value is not valid JSON', () => {
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value='not json'
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]});
|
||||
});
|
||||
|
||||
test('json mode falls back to an empty doc when value parses to a non-object', () => {
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value='"just a string"'
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]});
|
||||
});
|
||||
|
||||
test('markdown mode leaves enableContentCheck off', () => {
|
||||
renderWithContext(<WysiwygEditor {...baseProps}/>);
|
||||
|
||||
expect(mockCapturedConfig.current?.enableContentCheck).toBe(false);
|
||||
});
|
||||
|
||||
test('json mode enables enableContentCheck and forwards the callback', () => {
|
||||
const onContentError = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
contentType='json'
|
||||
onContentError={onContentError}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockCapturedConfig.current?.enableContentCheck).toBe(true);
|
||||
|
||||
const err = new Error('bad node');
|
||||
mockCapturedConfig.current?.onContentError?.({error: err});
|
||||
expect(onContentError).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
test('a post-mount content error forwards but does not latch hasContentError', () => {
|
||||
const onContentError = jest.fn();
|
||||
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
ref={ref}
|
||||
value='{"type":"doc","content":[]}'
|
||||
contentType='json'
|
||||
onContentError={onContentError}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(ref.current!.hasContentError()).toBe(false);
|
||||
|
||||
const err = new Error('bad insert');
|
||||
mockCapturedConfig.current?.onContentError?.({error: err});
|
||||
|
||||
expect(onContentError).toHaveBeenCalledWith(err);
|
||||
|
||||
// Latching here would permanently stall a consumer's autosave loop that
|
||||
// started from a clean load.
|
||||
expect(ref.current!.hasContentError()).toBe(false);
|
||||
});
|
||||
|
||||
test('a content error emitted during construction is deferred so a consumer can setState', () => {
|
||||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const seen: Array<Error | null> = [];
|
||||
const constructorError = new Error('schema mismatch');
|
||||
|
||||
mockConstructorError.current = constructorError;
|
||||
|
||||
// Models the real consumer: a parent that records the error in state.
|
||||
// If the editor emits during its own render, this setState happens while
|
||||
// rendering a different component and React logs an error.
|
||||
const Parent = () => {
|
||||
const [err, setErr] = React.useState<Error | null>(null);
|
||||
seen.push(err);
|
||||
return (
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value='{"type":"doc","content":[]}'
|
||||
contentType='json'
|
||||
onContentError={setErr}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
renderWithContext(<Parent/>);
|
||||
|
||||
expect(seen[seen.length - 1]).toBe(constructorError);
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
test('hasContentError latches when the initial load fails during construction', () => {
|
||||
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
|
||||
|
||||
mockConstructorError.current = new Error('schema mismatch');
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
ref={ref}
|
||||
value='{"type":"doc","content":[]}'
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(ref.current!.hasContentError()).toBe(true);
|
||||
});
|
||||
|
||||
test('contentType is frozen at mount and ignores a later prop change', () => {
|
||||
const onChange = jest.fn();
|
||||
const {rerender} = renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
contentType='markdown'
|
||||
/>,
|
||||
);
|
||||
|
||||
// Still json mode: paste stays short-circuited and updates stay JSON.
|
||||
expect(mockCapturedConfig.current?.editorProps?.handlePaste?.({}, {})).toBe(false);
|
||||
|
||||
jest.useFakeTimers();
|
||||
mockCapturedConfig.current?.onUpdate?.({editor: {getJSON: () => ({type: 'doc'})}});
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(JSON.stringify({type: 'doc'}));
|
||||
});
|
||||
|
||||
test('json mode reports a parse error via onContentError when value is unparseable', async () => {
|
||||
const onContentError = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value='not json'
|
||||
contentType='json'
|
||||
onContentError={onContentError}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(onContentError).toHaveBeenCalledTimes(1);
|
||||
expect(onContentError.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
test('handlePaste short-circuits in json mode; markdown mode still handles pastes', () => {
|
||||
const mkEvent = () => ({
|
||||
preventDefault: jest.fn(),
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === 'text/plain' ? '# heading' : ''),
|
||||
},
|
||||
}) as any;
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
expect(mockCapturedConfig.current?.editorProps?.handlePaste?.({} as any, mkEvent())).toBe(false);
|
||||
|
||||
mockCapturedConfig.current = null;
|
||||
renderWithContext(<WysiwygEditor {...baseProps}/>);
|
||||
|
||||
const result = mockCapturedConfig.current?.editorProps?.handlePaste?.({} as any, mkEvent());
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('getEditor() on the handle returns the underlying Tiptap Editor instance', () => {
|
||||
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editor = ref.current!.getEditor();
|
||||
expect(editor).not.toBeNull();
|
||||
expect(typeof (editor as any).getJSON).toBe('function');
|
||||
});
|
||||
|
||||
test.each([
|
||||
['null', 'null'],
|
||||
['array', '[1,2,3]'],
|
||||
['number', '42'],
|
||||
])('json mode falls back to empty doc when value parses to %s', (_label, raw) => {
|
||||
const onContentError = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value={raw}
|
||||
contentType='json'
|
||||
onContentError={onContentError}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]});
|
||||
expect(onContentError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('json mode does not throw when consumer omits onContentError for a bad value', () => {
|
||||
expect(() => {
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value='not json'
|
||||
contentType='json'
|
||||
/>,
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('handle.hasContentError() reflects load failure', async () => {
|
||||
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value='not json'
|
||||
contentType='json'
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(ref.current!.hasContentError()).toBe(true);
|
||||
});
|
||||
|
||||
test('handle.hasContentError() is false after a clean json load', () => {
|
||||
const ref = React.createRef<React.ComponentRef<typeof WysiwygEditor>>();
|
||||
|
||||
renderWithContext(
|
||||
<WysiwygEditor
|
||||
{...baseProps}
|
||||
value={JSON.stringify({type: 'doc', content: [{type: 'paragraph'}]})}
|
||||
contentType='json'
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(ref.current!.hasContentError()).toBe(false);
|
||||
});
|
||||
});
|
||||
+125
-32
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Extensions} from '@tiptap/core';
|
||||
import {Extension} from '@tiptap/core';
|
||||
import {CodeBlockLowlight} from '@tiptap/extension-code-block-lowlight';
|
||||
import Link from '@tiptap/extension-link';
|
||||
@@ -18,8 +19,9 @@ import {EditorContent, useEditor} from '@tiptap/react';
|
||||
import type {Editor} from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import isPlainObject from 'lodash/isPlainObject';
|
||||
import {common, createLowlight} from 'lowlight';
|
||||
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef} from 'react';
|
||||
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {editLatestPost} from 'actions/views/create_comment';
|
||||
@@ -91,11 +93,15 @@ export type WysiwygEditorHandle = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getInputBox: () => HTMLElement | null;
|
||||
|
||||
// True when the initial `value` failed to load in json mode. See
|
||||
// PublishedWysiwygEditorHandle for the autosave-gating contract.
|
||||
hasContentError: () => boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onChange: (content: string) => void;
|
||||
onSubmit: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
@@ -107,6 +113,26 @@ type Props = {
|
||||
useCtrlSend?: boolean;
|
||||
sendCodeBlockOnCtrlEnter?: boolean;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
contentType?: 'markdown' | 'json';
|
||||
extensions?: Extensions;
|
||||
onContentError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
const EMPTY_JSON_DOC = {type: 'doc', content: [{type: 'paragraph'}]} as const;
|
||||
|
||||
const parseJsonModeContent = (value: string): {content: string | Record<string, unknown>; error: Error | null} => {
|
||||
if (!value) {
|
||||
return {content: EMPTY_JSON_DOC, error: null};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (isPlainObject(parsed)) {
|
||||
return {content: parsed as Record<string, unknown>, error: null};
|
||||
}
|
||||
return {content: EMPTY_JSON_DOC, error: new Error('Invalid JSON content: expected an object doc')};
|
||||
} catch (err) {
|
||||
return {content: EMPTY_JSON_DOC, error: err instanceof Error ? err : new Error('Invalid JSON content')};
|
||||
}
|
||||
};
|
||||
|
||||
const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
|
||||
@@ -123,7 +149,13 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
|
||||
useCtrlSend = false,
|
||||
sendCodeBlockOnCtrlEnter = false,
|
||||
onKeyDown,
|
||||
contentType = 'markdown',
|
||||
extensions: extraExtensions,
|
||||
onContentError,
|
||||
}, ref) => {
|
||||
// Frozen: the Tiptap schema is fixed at construction, so a mid-flight prop
|
||||
// swap would desync the paste and update handlers from the actual editor.
|
||||
const jsonMode = useRef(contentType === 'json').current;
|
||||
const dispatch = useDispatch();
|
||||
const channelIdRef = useLatest(channelId);
|
||||
const rootIdRef = useLatest(rootId);
|
||||
@@ -144,6 +176,11 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
|
||||
}, SERIALIZE_DEBOUNCE_MS);
|
||||
|
||||
const handleUpdate = useCallback(({editor}: {editor: Editor}) => {
|
||||
if (jsonMode) {
|
||||
debouncedOnChange(JSON.stringify(editor.getJSON()));
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip artifacts the @tiptap/markdown serializer leaves around
|
||||
// empty paragraphs at doc start/end.
|
||||
const md = editor.getMarkdown().trimEnd().
|
||||
@@ -151,38 +188,89 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
|
||||
replace(/\n\n $/g, '').
|
||||
replace(/^ $/, '');
|
||||
debouncedOnChange(md);
|
||||
}, [debouncedOnChange]);
|
||||
}, [debouncedOnChange, jsonMode]);
|
||||
|
||||
const baseExtensions: Extensions = [
|
||||
StarterKit.configure({
|
||||
heading: {levels: [1, 2, 3, 4, 5, 6]},
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: () => placeholderRef.current,
|
||||
showOnlyCurrent: true,
|
||||
}),
|
||||
Table.configure({resizable: false, cellMinWidth: 80}),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
EmojiDecorations,
|
||||
];
|
||||
if (!jsonMode) {
|
||||
baseExtensions.push(Markdown.configure({markedOptions: {gfm: true}}));
|
||||
}
|
||||
if (extraExtensions?.length) {
|
||||
baseExtensions.push(...extraExtensions);
|
||||
}
|
||||
|
||||
const onContentErrorRef = useLatest(onContentError);
|
||||
const mountedRef = useRef(false);
|
||||
const pendingErrorRef = useRef<Error | null>(null);
|
||||
|
||||
// A ref, not state: the handle must report this synchronously, and a
|
||||
// consumer reading it from its own onContentError handler runs before any
|
||||
// re-render would land.
|
||||
const hasContentErrorRef = useRef(false);
|
||||
|
||||
const [initialContent] = useState<string | Record<string, unknown>>(() => {
|
||||
if (!jsonMode) {
|
||||
return value;
|
||||
}
|
||||
const {content, error} = parseJsonModeContent(value);
|
||||
if (error) {
|
||||
hasContentErrorRef.current = true;
|
||||
pendingErrorRef.current = error;
|
||||
}
|
||||
return content;
|
||||
});
|
||||
|
||||
const captureContentError = (error: Error) => {
|
||||
// Post-mount errors come from consumer-driven commands, not the initial
|
||||
// load, so they forward without latching hasContentError.
|
||||
if (mountedRef.current) {
|
||||
onContentErrorRef.current?.(error);
|
||||
return;
|
||||
}
|
||||
|
||||
hasContentErrorRef.current = true;
|
||||
pendingErrorRef.current = error;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
if (pendingErrorRef.current) {
|
||||
onContentErrorRef.current?.(pendingErrorRef.current);
|
||||
pendingErrorRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {levels: [1, 2, 3, 4, 5, 6]},
|
||||
codeBlock: false,
|
||||
link: false,
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: () => placeholderRef.current,
|
||||
showOnlyCurrent: true,
|
||||
}),
|
||||
Table.configure({resizable: false, cellMinWidth: 80}),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Markdown.configure({
|
||||
markedOptions: {gfm: true},
|
||||
}),
|
||||
EmojiDecorations,
|
||||
],
|
||||
content: value,
|
||||
contentType: 'markdown',
|
||||
extensions: baseExtensions,
|
||||
content: initialContent,
|
||||
contentType: jsonMode ? undefined : 'markdown',
|
||||
enableContentCheck: jsonMode,
|
||||
|
||||
// Tiptap emits this from the Editor constructor, which useEditor runs
|
||||
// during render — hence the buffering in captureContentError.
|
||||
onContentError: ({error}) => captureContentError(error),
|
||||
editable: !disabled,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
@@ -191,6 +279,10 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
|
||||
...(disabled ? {'aria-disabled': 'true', 'data-disabled': 'true'} : {'aria-disabled': 'false'}),
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (jsonMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = event.clipboardData?.getData('text/plain');
|
||||
if (!text) {
|
||||
return false;
|
||||
@@ -411,6 +503,7 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, Props>(({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
hasContentError: () => hasContentErrorRef.current,
|
||||
}), []);
|
||||
|
||||
const lastValueRef = useRef(value);
|
||||
|
||||
@@ -22,6 +22,7 @@ jest.mock('components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor', () =>
|
||||
blur: () => {},
|
||||
getInputBox: () => null,
|
||||
getEditor: () => null,
|
||||
hasContentError: () => false,
|
||||
}));
|
||||
return null;
|
||||
}),
|
||||
@@ -89,6 +90,8 @@ describe('WysiwygEditor handle forwarding', () => {
|
||||
expect(typeof handle!.focus).toBe('function');
|
||||
expect(typeof handle!.blur).toBe('function');
|
||||
expect(typeof handle!.getInputBox).toBe('function');
|
||||
expect(typeof handle!.getEditor).toBe('function');
|
||||
expect(typeof handle!.hasContentError).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export type ActionResult<Data = unknown, Error = unknown> = {
|
||||
|
||||
export type WysiwygEditorProps = {
|
||||
value: string;
|
||||
onChange: (markdown: string) => void;
|
||||
onChange: (content: string) => void;
|
||||
onSubmit: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
@@ -33,6 +33,16 @@ export type WysiwygEditorProps = {
|
||||
useCtrlSend?: boolean;
|
||||
sendCodeBlockOnCtrlEnter?: boolean;
|
||||
onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
// 'json' reads and emits stringified ProseMirror JSON. Mount-only.
|
||||
contentType?: 'markdown' | 'json';
|
||||
|
||||
// Mount-only. `any[]` so consumers don't need `@tiptap/core` transitively.
|
||||
extensions?: any[];
|
||||
|
||||
// Any content error in 'json' mode, for the editor's lifetime. See
|
||||
// hasContentError() for the autosave-gating contract.
|
||||
onContentError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export type SuggestionListProps = {
|
||||
@@ -121,6 +131,15 @@ export type PublishedWysiwygEditorHandle = {
|
||||
focus: () => void;
|
||||
blur: () => void;
|
||||
getInputBox: () => HTMLElement | null;
|
||||
|
||||
// Null until the mount effect runs, so a useLayoutEffect can still miss it.
|
||||
// In 'json' mode use getJSON(); getMarkdown() isn't attached.
|
||||
getEditor: () => any;
|
||||
|
||||
// True when the initial `value` failed to load in 'json' mode. Autosaving
|
||||
// consumers must gate the first onChange on this, or the empty fallback
|
||||
// overwrites the source. Load-only, so it can't stall a healthy session.
|
||||
hasContentError: () => boolean;
|
||||
};
|
||||
|
||||
export type PublishedFormattingBarHandle = {
|
||||
|
||||
Reference in New Issue
Block a user