refactor(markdown-editor): apply mechanical review batch (11 findings)

- M5: replace hand-rolled unknown-typed markdown types with the official
  @tiptap/core exports (MarkdownToken/ParseHelpers/RendererHelpers) and
  drop both casts they forced on the fidelity-critical paths
- M3: forward FormControl-injected id/aria-describedby/aria-invalid to the
  ProseMirror contenteditable (field<->label association + error announce)
- M2: skip the redundant whole-document re-parse on mount (compare against
  the captured initialContent instead of the normalized getMarkdown())
- L1+L10: extract the toolbar + isSafeImageSrc to markdown-editor-toolbar.tsx
  and memo() it (parent re-renders per keystroke under RHF)
- L2: delete dead autoFocus/hasToolbar props (zero consumers)
- L3: drop dead match.index narrowing (required number under es2023 lib)
- L4: own vite chunk for marked; drop stale tiptap-markdown chunk pattern
- L8: TunedStarterKit / TunedTable (consistent Tuned* prefix)
- L9: shouldExternalSync -> isExternalChange
- L14: isSafeImageSrc data: allowlist narrowed to base64 raster formats
  (data:image/svg+xml can carry script)
- L7: trim narration/restatement comments across module + tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey KozyrenkoandClaude Opus 4.8 committed 2026-07-04 12:07:30 +07:00
1 parent 2b54a0a6b4
commit 430306193e
13 files changed
+400 -379

No files matched your search

@@ -14,7 +14,7 @@ const nodeCount = (md: string, type: string): number => {
return count;
};
describe('code-block fence lengthening — a block documenting a ``` fence stays one block (M1)', () => {
describe('code-block fence lengthening — a block documenting a ``` fence stays one block', () => {
it('widens the outer fence past an inner ``` run so the block does not split on reload', () => {
const src = '````\ninner\n```\nnested fence\n```\nafter\n````';
const out = roundTrip(src);
@@ -33,7 +33,7 @@ describe('code-block fence lengthening — a block documenting a ``` fence stays
// delimiter by rendering it around a fixed placeholder (getMarkOpening/getMarkClosing), so the `code` mark's
// real content is never seen at delimiter time and it always emits a SINGLE backtick. A code span whose
// content contains a backtick therefore collapses on save and never converges. A NODE (codeBlock, above) DOES
// receive its real content and is content-aware — hence M1 is fixable but this is not. Pinned so the gap stays
// receive its real content and is content-aware — hence the fence widening is fixable but this is not. Pinned so the gap stays
// visible and this test flips green if a future @tiptap/markdown makes mark delimiters content-aware.
describe('inline code containing a backtick — known lossy on save (upstream mark-delimiter limitation)', () => {
it('collapses a backtick-containing code span (does not round-trip)', () => {
@@ -44,7 +44,7 @@ const WORDS = ['firewall', 'payload', 'exploit', 'nmap', 'recon', 'shell', 'toke
// Block contexts that keep inline content literal: paragraph, heading, bullet item, ordered item,
// blockquote, inline code. (Nested compositions — ordered>bullet>code, fence-in-fence — are exercised
// separately below; the historical ordered>bullet>code drop is fixed by the indented-fence + fence-widen work.)
// separately below.)
const wrap = (context: number, text: string): string => {
switch (context) {
case 1:
@@ -107,10 +107,10 @@ describe('generative content-integrity — atoms survive load↔serialize across
expect(roundTrip(source)).toBe(source);
});
// M3: the survives/converges oracles above only place atoms in SINGLE contexts. Nesting primitives inside
// one another (ordered>bullet, blockquote>list, list>code, fence-in-fence) is exactly the class that
// produced the historical ordered>bullet>code drop — assert structure (via structuralCounts, catching a
// dropped block/item) AND convergence AND atom survival across depth-≥2 compositions.
// The survives/converges oracles above only place atoms in SINGLE contexts. Nesting primitives inside
// one another (ordered>bullet, blockquote>list, list>code, fence-in-fence) is exactly the class where a
// whole block can drop silently — assert structure (via structuralCounts, catching a dropped block/item)
// AND convergence AND atom survival across depth-≥2 compositions.
it('nested primitive compositions preserve structure, content, and converge', () => {
const rng = mulberry32(0x5eeded);
const nestAtoms = ['{{.TargetURL}}', '<container_environment>', '__init__', 'C++ then C++', 'os.execute()'];
@@ -118,12 +118,12 @@ describe('named HTML entities decode outside code; numeric refs, bare & and raw
expect(roundTrip(src)).toBe(expected);
});
it.each([
'numeric &#123; and &#40; and &#x25; stay', // numeric refs are not decoded
'bare AT&T and a & b and 2>&1 survive', // a lone & is left literal
])('keeps %s byte-identical', (s) => {
expect(roundTrip(s)).toBe(s);
});
it.each(['numeric &#123; and &#40; and &#x25; stay', 'bare AT&T and a & b and 2>&1 survive'])(
'keeps %s byte-identical',
(s) => {
expect(roundTrip(s)).toBe(s);
},
);
it('entities inside inline code are preserved (code content is not decoded)', () => {
expect(roundTrip('`&lt;script&gt;`')).toBe('`&lt;script&gt;`');
@@ -174,7 +174,7 @@ describe('inline marks round-trip', () => {
});
});
describe('MarkdownTable — cell pipes escaped + alignment preserved, idempotent', () => {
describe('TunedTable — cell pipes escaped + alignment preserved, idempotent', () => {
it.each([
[
'pipe in a code-span cell',
@@ -221,10 +221,8 @@ describe('nesting & sequencing — content preserved and converges (≤2 saves)'
});
});
// A code block nested in a list (ordered > bullet > code) has an indented opening fence, so
// @tiptap/extension-code-block's `startsWith('```')` gate used to drop it — the same root cause as a
// top-level indented fence, fixed by parseTunedCodeBlock (see markdown-editor-extensions.ts). This was
// once a pinned KNOWN-BUG test asserting the loss; the fix now keeps the code.
// A code block nested in a list (ordered > bullet > code) has an indented opening fence — the same root
// cause as a top-level indented fence, covered by parseTunedCodeBlock (see markdown-editor-extensions.ts).
describe('ordered > bullet > code — indented nested code survives', () => {
it('keeps the deeply-nested code block', () => {
const out = roundTrip('1. lvl1\n - lvl2\n\n ```\n deepcode\n ```');
@@ -1,10 +1,18 @@
import type {
JSONContent,
MarkdownParseHelpers,
MarkdownParseResult,
MarkdownRendererHelpers,
MarkdownToken,
} from '@tiptap/core';
import { Image } from '@tiptap/extension-image';
import { TaskItem, TaskList } from '@tiptap/extension-list';
import { TableCell, TableHeader, TableRow } from '@tiptap/extension-table';
import { Placeholder } from '@tiptap/extensions';
import StarterKit from '@tiptap/starter-kit';
import { createMarkdownLayer, MarkdownTable } from './markdown-editor-marked';
import { createMarkdownLayer, TunedTable } from './markdown-editor-marked';
import { MarkdownPaste } from './markdown-editor-paste';
import { TagHighlight } from './markdown-editor-tag-highlight';
import { VariableHighlight } from './markdown-editor-variable-highlight';
@@ -12,24 +20,6 @@ import { VariableHighlight } from './markdown-editor-variable-highlight';
const dropUnderscore = (rules: { find: unknown }[]) =>
rules.filter((rule) => !(rule.find instanceof RegExp && rule.find.source.includes('_')));
interface MarkdownCodeToken {
codeBlockStyle?: string;
lang?: string;
raw?: string;
text?: string;
}
interface MarkdownParseHelpers {
createNode: (type: string, attrs: Record<string, unknown>, content: unknown[]) => unknown;
createTextNode: (text: string) => unknown;
}
interface MarkdownRenderHelpers {
renderChildren: (content: unknown) => string;
}
type MarkdownRenderNode = { attrs?: { language?: null | string }; content?: unknown };
const longestBacktickRun = (text: string): number =>
(text.match(/`+/g) ?? []).reduce((max, run) => Math.max(max, run.length), 0);
@@ -37,7 +27,7 @@ const longestBacktickRun = (text: string): number =>
// contains a ``` line (a doc demonstrating fenced markdown — common in knowledge/prompt examples) re-parses as
// TWO blocks on the next load: the inner fence closes the outer one. CommonMark requires the fence to be longer
// than any backtick run inside — widen it. Otherwise identical to upstream.
const renderTunedCodeBlock = (node: MarkdownRenderNode, helpers: MarkdownRenderHelpers): string => {
const renderTunedCodeBlock = (node: JSONContent, helpers: MarkdownRendererHelpers): string => {
const language = node.attrs?.language || '';
if (!node.content) {
@@ -55,7 +45,7 @@ const renderTunedCodeBlock = (node: MarkdownRenderNode, helpers: MarkdownRenderH
// token whose `raw` starts with that whitespace, the gate rejects it, and the block is dropped on load. When
// a document mixes fences at different indents the mis-detection cascades and everything after the first
// dropped fence vanishes too. Trim the leading indent before the gate; otherwise identical to upstream.
const parseTunedCodeBlock = (token: MarkdownCodeToken, helpers: MarkdownParseHelpers): unknown => {
const parseTunedCodeBlock = (token: MarkdownToken, helpers: MarkdownParseHelpers): MarkdownParseResult => {
const fence = token.raw?.trimStart() ?? '';
if (!fence.startsWith('```') && !fence.startsWith('~~~') && token.codeBlockStyle !== 'indented') {
@@ -74,7 +64,7 @@ const parseTunedCodeBlock = (token: MarkdownCodeToken, helpers: MarkdownParseHel
// `__init__`/`_word_` would emphasize (→ `**init**`/`*word*`) while the same text loaded stays literal,
// breaking identifiers. Drop only the underscore rules (their `find` regex mentions `_`; the `*` rules stay)
// so typing matches load. codeBlock gets the indented-fence fix above. (Underline off below.)
const StarterKitTuned = StarterKit.extend({
const TunedStarterKit = StarterKit.extend({
addExtensions() {
return (this.parent?.() ?? []).map((extension) => {
if (extension.name === 'bold' || extension.name === 'italic') {
@@ -92,7 +82,7 @@ const StarterKitTuned = StarterKit.extend({
return extension.extend({
parseMarkdown: parseTunedCodeBlock,
renderMarkdown: renderTunedCodeBlock,
} as Parameters<typeof extension.extend>[0]);
});
}
return extension;
@@ -109,12 +99,12 @@ const StarterKitTuned = StarterKit.extend({
// symmetric with the marked layer (which no longer neutralises autolink/url). Do NOT set false: it
// diverges typing from load and re-freezes bare URLs as text.
export const createMarkdownExtensions = (placeholder?: string) => [
StarterKitTuned.configure({
TunedStarterKit.configure({
codeBlock: { HTMLAttributes: { class: 'hljs' } },
link: { autolink: true, linkOnPaste: true },
underline: false,
}),
MarkdownTable.configure({ resizable: true }),
TunedTable.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
@@ -1,17 +1,18 @@
import { describe, expect, it } from 'vitest';
import { isSafeImageSrc } from './markdown-editor';
import { isSafeImageSrc } from './markdown-editor-toolbar';
// The Image extension stores whatever src it is handed; isSafeImageSrc is the allowlist that blocks dangerous
// PROTOCOLS (javascript:, data:text/html, vbscript:, file:) before a src is saved. A bare relative string
// resolves against the page origin (http/https) and is intentionally allowed — the guard validates the
// protocol, not that the URL is absolute.
// protocol, not that the URL is absolute. data: URLs are limited to base64 raster formats: svg+xml is the one
// image type that can carry script.
describe('isSafeImageSrc — image-src protocol allowlist', () => {
it.each([
'http://example.com/a.png',
'https://example.com/a.png',
'data:image/png;base64,AAAA',
'data:image/svg+xml;utf8,<svg/>',
'data:image/webp;base64,AAAA',
])('allows %s', (url) => {
expect(isSafeImageSrc(url)).toBe(true);
});
@@ -20,6 +21,8 @@ describe('isSafeImageSrc — image-src protocol allowlist', () => {
'javascript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'data:application/pdf;base64,AA',
'data:image/svg+xml;utf8,<svg onload=alert(1)/>',
'data:image/svg+xml;base64,AAAA',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'http://', // malformed → URL constructor throws → rejected via the catch
@@ -37,7 +37,7 @@ export const collectInlineMatches = (doc: PMNode, regex: RegExp): InlineMatch[]
});
for (const match of text.matchAll(regex)) {
const start = match.index ?? 0;
const start = match.index;
const from = positions[start];
const last = positions[start + match[0].length - 1];
@@ -1,4 +1,4 @@
import type { JSONContent } from '@tiptap/core';
import type { JSONContent, MarkdownRendererHelpers } from '@tiptap/core';
import { Extension } from '@tiptap/core';
import { renderTableToMarkdown, Table } from '@tiptap/extension-table';
@@ -71,16 +71,14 @@ const TunedMarkdownText = Extension.create({
// that one call to escape pipes fixes the save side, on the official alignment-aware renderer.
// This does NOT (and cannot) help the LOAD side: a raw `|` inside inline code arriving in external markdown is
// split by marked's GFM table tokenizer BEFORE the inline-code tokenizer runs — a marked limitation.
type RenderHelpers = { renderChildren: (nodes: JSONContent | JSONContent[], separator?: string) => string };
export const MarkdownTable = Table.extend({
renderMarkdown(node: JSONContent, helpers: RenderHelpers) {
const pipeEscaping: RenderHelpers = {
export const TunedTable = Table.extend({
renderMarkdown(node: JSONContent, helpers: MarkdownRendererHelpers) {
const pipeEscaping: MarkdownRendererHelpers = {
...helpers,
renderChildren: (nodes, separator) => helpers.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
};
return renderTableToMarkdown(node, pipeEscaping as Parameters<typeof renderTableToMarkdown>[1]);
return renderTableToMarkdown(node, pipeEscaping);
},
});
@@ -12,7 +12,6 @@ export const setupEditorJsdom = (): void => {
({ item: () => null, length: 0, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
};
// The folder's standard load↔save round-trip: parse markdown into a headless editor and re-serialize.
export const roundTrip = (markdown: string): string => {
const editor = new Editor({ content: markdown, contentType: 'markdown', extensions: createMarkdownExtensions() });
const out = editor.getMarkdown();
@@ -0,0 +1,302 @@
import type { Editor } from '@tiptap/react';
import { useEditorState } from '@tiptap/react';
import {
Bold,
Code,
Code2,
Heading1,
Heading2,
Heading3,
ImagePlus,
Italic,
Link as LinkIcon,
List,
ListOrdered,
ListTodo,
Minus,
Quote,
Redo,
Strikethrough,
Table,
Undo,
} from 'lucide-react';
import { memo, useCallback } from 'react';
import { Separator } from '@/components/ui/separator';
import { Toggle } from '@/components/ui/toggle';
import { cn } from '@/lib/utils';
interface MarkdownEditorToolbarProps {
disabled?: boolean;
editor: Editor;
}
// The Image extension doesn't validate the src protocol, so the toolbar Insert-image button rejects
// non-http(s)/non-raster-data URLs (javascript:, data:text/html, data:image/svg+xml — SVG can carry script).
// Guards ONLY that button — images entering via markdown load/paste bypass it (inert in an <img src>; the
// read-only viewer sanitizes protocols on render).
export const isSafeImageSrc = (url: string): boolean => {
try {
const { protocol } = new URL(url, window.location.href);
return (
protocol === 'http:' || protocol === 'https:' || /^data:image\/(png|jpe?g|gif|webp|bmp);base64,/i.test(url)
);
} catch {
return false;
}
};
// memo: every keystroke re-renders the RHF-controlled parent; without it all ~20 Toggle subtrees re-render
// per keystroke for referentially-stable props.
export const MarkdownEditorToolbar = memo(function MarkdownEditorToolbar({
disabled,
editor,
}: MarkdownEditorToolbarProps) {
const handleSetLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('URL', previousUrl ?? '');
if (url === null) {
return;
}
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
// tiptap v3's useEditor does NOT re-render on every transaction, so reading editor.isActive/can() inline
// would leave the toolbar stale on selection-only moves (click into a bold word → Bold stays unlit).
// useEditorState re-runs this selector per transaction and re-renders only when a button's state flips.
const state = useEditorState({
editor,
selector: ({ editor }) => ({
canRedo: editor.can().redo(),
canUndo: editor.can().undo(),
isBlockquote: editor.isActive('blockquote'),
isBold: editor.isActive('bold'),
isBulletList: editor.isActive('bulletList'),
isCode: editor.isActive('code'),
isCodeBlock: editor.isActive('codeBlock'),
isH1: editor.isActive('heading', { level: 1 }),
isH2: editor.isActive('heading', { level: 2 }),
isH3: editor.isActive('heading', { level: 3 }),
isItalic: editor.isActive('italic'),
isLink: editor.isActive('link'),
isOrderedList: editor.isActive('orderedList'),
isStrike: editor.isActive('strike'),
isTable: editor.isActive('table'),
isTaskList: editor.isActive('taskList'),
}),
});
return (
<div
className={cn(
'bg-muted/40 flex flex-wrap items-center gap-0.5 border-b px-1 py-1',
disabled && 'pointer-events-none opacity-60',
)}
data-slot="markdown-editor-toolbar"
>
<Toggle
aria-label="Bold"
onPressedChange={() => editor.chain().focus().toggleBold().run()}
pressed={state.isBold}
size="sm"
title="Bold (Ctrl+B)"
>
<Bold />
</Toggle>
<Toggle
aria-label="Italic"
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
pressed={state.isItalic}
size="sm"
title="Italic (Ctrl+I)"
>
<Italic />
</Toggle>
<Toggle
aria-label="Strikethrough"
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
pressed={state.isStrike}
size="sm"
title="Strikethrough"
>
<Strikethrough />
</Toggle>
<Toggle
aria-label="Inline code"
onPressedChange={() => editor.chain().focus().toggleCode().run()}
pressed={state.isCode}
size="sm"
title="Inline code"
>
<Code />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Heading 1"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
pressed={state.isH1}
size="sm"
title="Heading 1"
>
<Heading1 />
</Toggle>
<Toggle
aria-label="Heading 2"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
pressed={state.isH2}
size="sm"
title="Heading 2"
>
<Heading2 />
</Toggle>
<Toggle
aria-label="Heading 3"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
pressed={state.isH3}
size="sm"
title="Heading 3"
>
<Heading3 />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Bullet list"
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
pressed={state.isBulletList}
size="sm"
title="Bullet list"
>
<List />
</Toggle>
<Toggle
aria-label="Ordered list"
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
pressed={state.isOrderedList}
size="sm"
title="Ordered list"
>
<ListOrdered />
</Toggle>
<Toggle
aria-label="Task list"
onPressedChange={() => editor.chain().focus().toggleTaskList().run()}
pressed={state.isTaskList}
size="sm"
title="Task list"
>
<ListTodo />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Blockquote"
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
pressed={state.isBlockquote}
size="sm"
title="Blockquote"
>
<Quote />
</Toggle>
<Toggle
aria-label="Code block"
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
pressed={state.isCodeBlock}
size="sm"
title="Code block"
>
<Code2 />
</Toggle>
<Toggle
aria-label="Link"
onPressedChange={handleSetLink}
pressed={state.isLink}
size="sm"
title="Insert link"
>
<LinkIcon />
</Toggle>
<Toggle
aria-label="Insert image"
onPressedChange={() => {
const url = window.prompt('Image URL');
if (url && isSafeImageSrc(url)) {
editor.chain().focus().setImage({ src: url }).run();
}
}}
pressed={false}
size="sm"
title="Insert image"
>
<ImagePlus />
</Toggle>
<Toggle
aria-label="Horizontal rule"
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
pressed={false}
size="sm"
title="Horizontal rule"
>
<Minus />
</Toggle>
<Toggle
aria-label="Insert table"
onPressedChange={() =>
editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: true }).run()
}
pressed={state.isTable}
size="sm"
title="Insert table"
>
<Table />
</Toggle>
<div className="ml-auto flex items-center gap-0.5">
<Toggle
aria-label="Undo"
disabled={!state.canUndo}
onPressedChange={() => editor.chain().focus().undo().run()}
pressed={false}
size="sm"
title="Undo (Ctrl+Z)"
>
<Undo />
</Toggle>
<Toggle
aria-label="Redo"
disabled={!state.canRedo}
onPressedChange={() => editor.chain().focus().redo().run()}
pressed={false}
size="sm"
title="Redo (Ctrl+Shift+Z)"
>
<Redo />
</Toggle>
</div>
</div>
);
});
@@ -19,7 +19,7 @@ export const findVariableUseRanges = (value: string, variable: string): { index:
const ranges: { index: number; length: number }[] = [];
for (const match of value.matchAll(VARIABLE_RE)) {
if (match.index !== undefined && probe.test(match[0])) {
if (probe.test(match[0])) {
ranges.push({ index: match.index, length: match[0].length });
}
}
@@ -1,35 +1,15 @@
import type { Editor } from '@tiptap/react';
import type { AriaAttributes, Ref } from 'react';
import { history } from '@tiptap/pm/history';
import { EditorState, TextSelection } from '@tiptap/pm/state';
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
import {
Bold,
Code,
Code2,
Heading1,
Heading2,
Heading3,
ImagePlus,
Italic,
Link as LinkIcon,
List,
ListOrdered,
ListTodo,
Minus,
Quote,
Redo,
Strikethrough,
Table,
Undo,
} from 'lucide-react';
import { type Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { EditorContent, useEditor } from '@tiptap/react';
import { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { Separator } from '@/components/ui/separator';
import { Toggle } from '@/components/ui/toggle';
import { cn } from '@/lib/utils';
import { createMarkdownExtensions } from './markdown-editor-extensions';
import { MarkdownEditorToolbar } from './markdown-editor-toolbar';
import { findVariableOccurrences } from './markdown-editor-variable-highlight';
export interface MarkdownEditorHandle {
@@ -38,11 +18,12 @@ export interface MarkdownEditorHandle {
}
interface MarkdownEditorProps {
autoFocus?: boolean;
'aria-describedby'?: string;
'aria-invalid'?: AriaAttributes['aria-invalid'];
className?: string;
contentClassName?: string;
disabled?: boolean;
hasToolbar?: boolean;
id?: string;
onBlur?: () => void;
onChange: (value: string) => void;
placeholder?: string;
@@ -87,21 +68,16 @@ export const resetUndoHistory = (editor: Editor): void => {
view.dispatch(view.state.tr.setMeta('addToHistory', false));
};
interface MarkdownEditorToolbarProps {
disabled?: boolean;
editor: Editor;
}
// Shared by the loading shell and the mounted editor so the two wrappers cannot drift.
const WRAPPER_CLASS =
'border-input dark:bg-input/30 group/markdown-editor flex w-full flex-col overflow-hidden rounded-md border shadow-2xs outline-hidden transition-[color,box-shadow]';
function MarkdownEditor({
autoFocus,
'aria-describedby': ariaDescribedby,
'aria-invalid': ariaInvalid,
className,
contentClassName,
disabled,
hasToolbar = true,
id,
onBlur,
onChange,
placeholder = 'Write something…',
@@ -218,11 +194,15 @@ function MarkdownEditor({
return;
}
const current = editor.getMarkdown();
const shouldExternalSync = current !== value;
// On mount the construction-time parse already reflects `initialContent`, so compare against THAT, not
// getMarkdown(): serialization normalizes most real documents, so `getMarkdown() !== value` on mount
// would re-parse the identical doc — doubling mount cost for exactly the large documents where parse
// dominates. After mount, getMarkdown() is the right divergence check for external form.reset syncs.
const isExternalChange = hasResetInitialHistoryRef.current
? editor.getMarkdown() !== value
: value !== initialContent;
if (shouldExternalSync) {
// emitUpdate:false — an external sync (form.reset) is not a user edit, so it must not fire onChange.
if (isExternalChange) {
editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
}
@@ -232,16 +212,15 @@ function MarkdownEditor({
// Clear the undo stack:
// - on initial mount, to discard the construction-time
// transactions dispatched by extensions like `trailingNode`
// plus the initial `setContent` we just ran above;
// transactions dispatched by extensions like `trailingNode`;
// - on every external `setContent` (e.g. parent calls
// form.reset(serverDocument)), because that transaction is
// not a user edit either.
if (!hasResetInitialHistoryRef.current || shouldExternalSync) {
if (!hasResetInitialHistoryRef.current || isExternalChange) {
hasResetInitialHistoryRef.current = true;
resetUndoHistory(editor);
}
}, [editor, value]);
}, [editor, value, initialContent]);
useEffect(() => {
if (!editor || editor.isEditable === !disabled) {
@@ -251,16 +230,29 @@ function MarkdownEditor({
editor.setEditable(!disabled);
}, [editor, disabled]);
// FormControl (Radix Slot) injects these; forward them to the ProseMirror contenteditable. NOT via
// reactive editorProps: setOptions(editorProps) wholesale-replaces `attributes` (dropping tiptap's
// default role="textbox") and diffs by object identity. PM only removes attributes it set itself, so
// writing ours directly on view.dom survives its updates.
useEffect(() => {
if (autoFocus && editor) {
editor.commands.focus('end');
if (!editor) {
return;
}
// `autoFocus` is intentionally omitted from the deps — it's a
// mount-time prop, equivalent to the native `<input autoFocus>`
// attribute. We don't want a parent flipping `autoFocus` later
// to steal focus back into the editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
const { dom } = editor.view;
for (const [name, attribute] of Object.entries({
'aria-describedby': ariaDescribedby,
'aria-invalid': ariaInvalid,
id,
})) {
if (attribute === undefined) {
dom.removeAttribute(name);
} else {
dom.setAttribute(name, String(attribute));
}
}
}, [editor, ariaDescribedby, ariaInvalid, id]);
if (!editor) {
return (
@@ -282,12 +274,10 @@ function MarkdownEditor({
)}
data-slot="markdown-editor"
>
{hasToolbar ? (
<MarkdownEditorToolbar
disabled={disabled}
editor={editor}
/>
) : null}
<MarkdownEditorToolbar
disabled={disabled}
editor={editor}
/>
<EditorContent
className={cn(
'prose prose-sm dark:prose-invert tiptap-content max-w-none min-w-0 flex-1 overflow-auto px-3 py-2',
@@ -300,265 +290,4 @@ function MarkdownEditor({
);
}
// The Image extension doesn't validate the src protocol, so the toolbar Insert-image button rejects
// non-http(s)/non-image-data URLs (javascript:, data:text/html, …). Guards ONLY that button — images entering
// via markdown load/paste bypass it (inert in an <img src>; the read-only viewer sanitizes protocols on render).
export const isSafeImageSrc = (url: string): boolean => {
try {
const { protocol } = new URL(url, window.location.href);
return protocol === 'http:' || protocol === 'https:' || /^data:image\//i.test(url);
} catch {
return false;
}
};
function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps) {
const handleSetLink = useCallback(() => {
const previousUrl = editor.getAttributes('link').href as string | undefined;
const url = window.prompt('URL', previousUrl ?? '');
if (url === null) {
return;
}
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
// tiptap v3's useEditor does NOT re-render on every transaction, so reading editor.isActive/can() inline
// would leave the toolbar stale on selection-only moves (click into a bold word → Bold stays unlit).
// useEditorState re-runs this selector per transaction and re-renders only when a button's state flips.
const state = useEditorState({
editor,
selector: ({ editor }) => ({
canRedo: editor.can().redo(),
canUndo: editor.can().undo(),
isBlockquote: editor.isActive('blockquote'),
isBold: editor.isActive('bold'),
isBulletList: editor.isActive('bulletList'),
isCode: editor.isActive('code'),
isCodeBlock: editor.isActive('codeBlock'),
isH1: editor.isActive('heading', { level: 1 }),
isH2: editor.isActive('heading', { level: 2 }),
isH3: editor.isActive('heading', { level: 3 }),
isItalic: editor.isActive('italic'),
isLink: editor.isActive('link'),
isOrderedList: editor.isActive('orderedList'),
isStrike: editor.isActive('strike'),
isTable: editor.isActive('table'),
isTaskList: editor.isActive('taskList'),
}),
});
return (
<div
className={cn(
'bg-muted/40 flex flex-wrap items-center gap-0.5 border-b px-1 py-1',
disabled && 'pointer-events-none opacity-60',
)}
data-slot="markdown-editor-toolbar"
>
<Toggle
aria-label="Bold"
onPressedChange={() => editor.chain().focus().toggleBold().run()}
pressed={state.isBold}
size="sm"
title="Bold (Ctrl+B)"
>
<Bold />
</Toggle>
<Toggle
aria-label="Italic"
onPressedChange={() => editor.chain().focus().toggleItalic().run()}
pressed={state.isItalic}
size="sm"
title="Italic (Ctrl+I)"
>
<Italic />
</Toggle>
<Toggle
aria-label="Strikethrough"
onPressedChange={() => editor.chain().focus().toggleStrike().run()}
pressed={state.isStrike}
size="sm"
title="Strikethrough"
>
<Strikethrough />
</Toggle>
<Toggle
aria-label="Inline code"
onPressedChange={() => editor.chain().focus().toggleCode().run()}
pressed={state.isCode}
size="sm"
title="Inline code"
>
<Code />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Heading 1"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
pressed={state.isH1}
size="sm"
title="Heading 1"
>
<Heading1 />
</Toggle>
<Toggle
aria-label="Heading 2"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
pressed={state.isH2}
size="sm"
title="Heading 2"
>
<Heading2 />
</Toggle>
<Toggle
aria-label="Heading 3"
onPressedChange={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
pressed={state.isH3}
size="sm"
title="Heading 3"
>
<Heading3 />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Bullet list"
onPressedChange={() => editor.chain().focus().toggleBulletList().run()}
pressed={state.isBulletList}
size="sm"
title="Bullet list"
>
<List />
</Toggle>
<Toggle
aria-label="Ordered list"
onPressedChange={() => editor.chain().focus().toggleOrderedList().run()}
pressed={state.isOrderedList}
size="sm"
title="Ordered list"
>
<ListOrdered />
</Toggle>
<Toggle
aria-label="Task list"
onPressedChange={() => editor.chain().focus().toggleTaskList().run()}
pressed={state.isTaskList}
size="sm"
title="Task list"
>
<ListTodo />
</Toggle>
<Separator
className="mx-1 h-5"
orientation="vertical"
/>
<Toggle
aria-label="Blockquote"
onPressedChange={() => editor.chain().focus().toggleBlockquote().run()}
pressed={state.isBlockquote}
size="sm"
title="Blockquote"
>
<Quote />
</Toggle>
<Toggle
aria-label="Code block"
onPressedChange={() => editor.chain().focus().toggleCodeBlock().run()}
pressed={state.isCodeBlock}
size="sm"
title="Code block"
>
<Code2 />
</Toggle>
<Toggle
aria-label="Link"
onPressedChange={handleSetLink}
pressed={state.isLink}
size="sm"
title="Insert link"
>
<LinkIcon />
</Toggle>
<Toggle
aria-label="Insert image"
onPressedChange={() => {
const url = window.prompt('Image URL');
if (url && isSafeImageSrc(url)) {
editor.chain().focus().setImage({ src: url }).run();
}
}}
pressed={false}
size="sm"
title="Insert image"
>
<ImagePlus />
</Toggle>
<Toggle
aria-label="Horizontal rule"
onPressedChange={() => editor.chain().focus().setHorizontalRule().run()}
pressed={false}
size="sm"
title="Horizontal rule"
>
<Minus />
</Toggle>
<Toggle
aria-label="Insert table"
onPressedChange={() =>
editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: true }).run()
}
pressed={state.isTable}
size="sm"
title="Insert table"
>
<Table />
</Toggle>
<div className="ml-auto flex items-center gap-0.5">
<Toggle
aria-label="Undo"
disabled={!state.canUndo}
onPressedChange={() => editor.chain().focus().undo().run()}
pressed={false}
size="sm"
title="Undo (Ctrl+Z)"
>
<Undo />
</Toggle>
<Toggle
aria-label="Redo"
disabled={!state.canRedo}
onPressedChange={() => editor.chain().focus().redo().run()}
pressed={false}
size="sm"
title="Redo (Ctrl+Shift+Z)"
>
<Redo />
</Toggle>
</div>
</div>
);
}
export { MarkdownEditor };
@@ -201,11 +201,8 @@ export function KnowledgeForm({ initialValues, isNew, knowledge, onSubmit }: Kno
const form = useForm<FormValues>({
defaultValues: initialValues,
// `onTouched` validates a field on its first blur and on every change
// afterwards. With `onChange` we'd run the entire Zod schema on every
// keystroke (including every emit from the multi-kilobyte `content`
// markdown editor) — same UX after the first interaction, no waste
// on initial mount or untouched fields.
// Not `onChange`: that would run the entire Zod schema on every keystroke, including every emit
// from the multi-kilobyte `content` markdown editor.
mode: 'onTouched',
resetOptions: {
// When `values` changes (e.g. a GraphQL subscription pushes an
+6 -1
View File
@@ -86,9 +86,14 @@ export default defineConfig(({ mode }) => {
if (/[\\/](monaco-editor|@monaco-editor[\\/]react)[\\/]/.test(id)) {
return 'monaco';
}
if (/[\\/](@tiptap|tiptap-markdown|prosemirror-[a-z-]+)[\\/]/.test(id)) {
if (/[\\/](@tiptap|prosemirror-[a-z-]+)[\\/]/.test(id)) {
return 'tiptap';
}
// Without an own chunk, marked gets folded into the 527KB tiptap chunk, and report-pdf's
// static `import { marked }` would download all of it for a ~40KB library.
if (/[\\/]node_modules[\\/]marked[\\/]/.test(id)) {
return 'marked';
}
if (/[\\/](react|react-dom|react-router-dom)[\\/]/.test(id)) {
return 'react-vendor';
}