diff --git a/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.test.tsx b/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.test.tsx
new file mode 100644
index 00000000000..55af63b4ad7
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.test.tsx
@@ -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();
+
+ expect(extensionNames()).toContain('markdown');
+ expect(mockCapturedConfig.current?.contentType).toBe('markdown');
+ });
+
+ test('json mode omits the Markdown extension and drops the markdown contentType', () => {
+ renderWithContext(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(mockCapturedConfig.current?.content).toEqual(doc);
+ });
+
+ test('json mode falls back to an empty doc when value is not valid JSON', () => {
+ renderWithContext(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ expect(mockCapturedConfig.current?.content).toEqual({type: 'doc', content: [{type: 'paragraph'}]});
+ });
+
+ test('markdown mode leaves enableContentCheck off', () => {
+ renderWithContext();
+
+ expect(mockCapturedConfig.current?.enableContentCheck).toBe(false);
+ });
+
+ test('json mode enables enableContentCheck and forwards the callback', () => {
+ const onContentError = jest.fn();
+
+ renderWithContext(
+ ,
+ );
+
+ 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>();
+
+ renderWithContext(
+ ,
+ );
+
+ 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 = [];
+ 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(null);
+ seen.push(err);
+ return (
+
+ );
+ };
+
+ renderWithContext();
+
+ 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>();
+
+ mockConstructorError.current = new Error('schema mismatch');
+
+ renderWithContext(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+ expect(mockCapturedConfig.current?.editorProps?.handlePaste?.({} as any, mkEvent())).toBe(false);
+
+ mockCapturedConfig.current = null;
+ renderWithContext();
+
+ 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>();
+
+ renderWithContext(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+ }).not.toThrow();
+ });
+
+ test('handle.hasContentError() reflects load failure', async () => {
+ const ref = React.createRef>();
+
+ renderWithContext(
+ ,
+ );
+
+ expect(ref.current!.hasContentError()).toBe(true);
+ });
+
+ test('handle.hasContentError() is false after a clean json load', () => {
+ const ref = React.createRef>();
+
+ renderWithContext(
+ ,
+ );
+
+ expect(ref.current!.hasContentError()).toBe(false);
+ });
+});
diff --git a/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.tsx b/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.tsx
index 1cd6947382a..def0a1c883d 100644
--- a/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/wysiwyg_editor/wysiwyg_editor.tsx
@@ -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) => 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; 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, 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(({
@@ -123,7 +149,13 @@ const WysiwygEditor = forwardRef(({
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(({
}, 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(({
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(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>(() => {
+ 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(({
...(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(({
}
return null;
},
+ hasContentError: () => hasContentErrorRef.current,
}), []);
const lastValueRef = useRef(value);
diff --git a/webapp/channels/src/plugins/published_editor.test.tsx b/webapp/channels/src/plugins/published_editor.test.tsx
index d22d99ad4d0..f809e9608c3 100644
--- a/webapp/channels/src/plugins/published_editor.test.tsx
+++ b/webapp/channels/src/plugins/published_editor.test.tsx
@@ -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');
});
});
diff --git a/webapp/platform/shared/src/types/global/editor.ts b/webapp/platform/shared/src/types/global/editor.ts
index 64e78d37a63..34ea111dbef 100644
--- a/webapp/platform/shared/src/types/global/editor.ts
+++ b/webapp/platform/shared/src/types/global/editor.ts
@@ -21,7 +21,7 @@ export type ActionResult = {
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) => 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 = {