mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-26 13:06:32 +00:00
feat(editor): migrate markdown round-trip to official @tiptap/markdown
Replace the unified rich editor's markdown engine: drop the community tiptap-markdown (markdown-it parse + prosemirror-markdown serialize) and our 5 prosemirror-internal monkey-patch extensions for the official @tiptap/markdown (marked-based MarkdownManager). The consumer moves to the new API (editor.getMarkdown() / contentType:'markdown' / markdown.parse). editor-markdown.ts adds three small, supported-API customizations: - a private HTML-neutralized marked instance so literal <xml-tags> survive (marked otherwise swallows real-HTML-element names like <input>); - FaithfulMarkdownText overrides MarkdownManager.encodeTextForMarkdown to drop entity-encoding and over-escaping of literal punctuation; - MarkdownTable wraps renderTableToMarkdown to escape cell pipes (#7884). Verified: tsc, 747 vitest (rewritten extension + 39-prompt corpus tests), lint, build; live on the dev stand — knowledge tables render and resize, prompt <tags> stay literal (124 tag + 116 variable highlights, no entity-encoding), console clean. Known accepted bug, pinned by a test (marked parser, 0 corpus impact): a code block nested ordered-list > bullet-sublist > code is dropped on parse. Repro recorded for an upstream report. Folds in the in-progress unified-editor work it depends on: knowledge Plain/Visual toggle, settings-prompt and template editor wiring, the CodeMirror removal, and the editor table/tag/variable styles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
18839b3487
commit
e1e7817dd6
+12
-8
@@ -18,7 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^4.2.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@graphql-typed-document-node/core": "^3.2.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-accordion": "^1.2.14",
|
||||
@@ -44,12 +43,18 @@
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"@tiptap/core": "^3.27.0",
|
||||
"@tiptap/extensions": "^3.27.0",
|
||||
"@tiptap/pm": "^3.27.0",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"@uiw/react-codemirror": "^4.25.10",
|
||||
"@tiptap/core": "^3.27.1",
|
||||
"@tiptap/extension-code": "^3.27.1",
|
||||
"@tiptap/extension-code-block": "^3.27.1",
|
||||
"@tiptap/extension-hard-break": "^3.27.1",
|
||||
"@tiptap/extension-image": "^3.27.1",
|
||||
"@tiptap/extension-list": "^3.27.1",
|
||||
"@tiptap/extension-table": "^3.27.1",
|
||||
"@tiptap/extensions": "^3.27.1",
|
||||
"@tiptap/markdown": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
@@ -86,7 +91,6 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-debounce": "^10.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
Generated
+249
-563
File diff suppressed because it is too large
Load Diff
@@ -1,52 +0,0 @@
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import CodeMirror, { EditorView, type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { type Ref, useMemo } from 'react';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type { ReactCodeMirrorRef };
|
||||
|
||||
export interface CodeEditorProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onBlur?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
ref?: Ref<ReactCodeMirrorRef>;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const extensions = [markdown(), EditorView.lineWrapping];
|
||||
|
||||
/**
|
||||
* Byte-faithful text editor: CodeMirror edits the raw document string verbatim, with no
|
||||
* parse/serialize round-trip. Don't swap in a markdown/rich editor — it would corrupt
|
||||
* Go-template tables, `<tags>`, and significant whitespace on round-trip.
|
||||
*/
|
||||
export function CodeEditor({ className, disabled, onBlur, onChange, placeholder, ref, value }: CodeEditorProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = useMemo(
|
||||
() => theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches),
|
||||
[theme],
|
||||
);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
className={cn(
|
||||
'border-input dark:bg-input/30 focus-within:ring-ring h-full overflow-hidden rounded-md border text-sm shadow-2xs focus-within:ring-1',
|
||||
disabled && 'pointer-events-none opacity-60',
|
||||
className,
|
||||
)}
|
||||
editable={!disabled}
|
||||
extensions={extensions}
|
||||
height="100%"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
ref={ref}
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TAG_RE } from './editor-tag-highlight';
|
||||
import { VARIABLE_RE } from './editor-variable-highlight';
|
||||
|
||||
// Decorations highlight per text node via `node.text.matchAll(RE)`; assert the RE itself matches the
|
||||
// right spans and — critically — does NOT false-positive on prose that merely contains < or {{.
|
||||
const matches = (re: RegExp, text: string) => [...text.matchAll(re)].map((m) => m[0]);
|
||||
|
||||
describe('VARIABLE_RE — {{ go-template actions }}', () => {
|
||||
it.each([
|
||||
['{{.Var}}', ['{{.Var}}']],
|
||||
['{{- if .X}}', ['{{- if .X}}']],
|
||||
['{{end}}', ['{{end}}']],
|
||||
['{{.X | upper}}', ['{{.X | upper}}']],
|
||||
['{{printf "%d" .N}}', ['{{printf "%d" .N}}']],
|
||||
['{{- .B -}}', ['{{- .B -}}']],
|
||||
['a {{.X}} b {{.Y}} c', ['{{.X}}', '{{.Y}}']],
|
||||
])('matches %s', (input, expected) => {
|
||||
expect(matches(VARIABLE_RE, input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each(['{single brace}', '${shell}', 'open {{ no close', 'a }} before {{ open'])(
|
||||
'does NOT match %s',
|
||||
(input) => {
|
||||
expect(matches(VARIABLE_RE, input)).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('TAG_RE — <xml-like tags>', () => {
|
||||
it.each([
|
||||
['<container_environment>', ['<container_environment>']],
|
||||
['</language_policy>', ['</language_policy>']],
|
||||
['<specialist name="searcher">', ['<specialist name="searcher">']],
|
||||
['<tool/>', ['<tool/>']],
|
||||
['<a_b-c>', ['<a_b-c>']],
|
||||
])('matches %s', (input, expected) => {
|
||||
expect(matches(TAG_RE, input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'count a < b here', // less-than, space after <
|
||||
'loop x<5 times', // digit after <
|
||||
'rating <3', // not a tag
|
||||
'empty <>',
|
||||
'spaced < tag>',
|
||||
'<https://example.com>', // markdown autolink — must NOT be a tag
|
||||
'<!-- a comment -->', // html comment
|
||||
])('does NOT match %s', (input) => {
|
||||
expect(matches(TAG_RE, input)).toEqual([]);
|
||||
});
|
||||
|
||||
it('documented false-positive: a single-letter <b> in prose is highlighted (harmless, view-only)', () => {
|
||||
expect(matches(TAG_RE, 'if a<b> then')).toEqual(['<b>']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { renderTableToMarkdown, Table } from '@tiptap/extension-table';
|
||||
import { Markdown } from '@tiptap/markdown';
|
||||
import { Marked } from 'marked';
|
||||
|
||||
// @tiptap/markdown parses with `marked`, which — unlike markdown-it's `html: false` — always tries to
|
||||
// interpret `<...>` as HTML. Our content uses literal XML-ish tags (`<container_environment>`, `<input>`)
|
||||
// that must survive verbatim; marked silently swallows the ones whose names match real HTML elements
|
||||
// (`<input>`, `<br>`, …). Neutralising marked's block (`html`) and inline (`tag`) HTML tokenizers makes
|
||||
// every `<...>` fall through to plain text, recreating markdown-it's `html: false`.
|
||||
const createFaithfulMarked = () => {
|
||||
const instance = new Marked();
|
||||
|
||||
instance.use({ tokenizer: { html: () => undefined, tag: () => undefined } });
|
||||
|
||||
// A PRIVATE instance — mutating the shared global `marked` would also affect report-pdf.
|
||||
return instance;
|
||||
};
|
||||
|
||||
// marked-side parsing keeps tags literal; this is the serialize-side counterpart. @tiptap/markdown's
|
||||
// MarkdownManager.encodeTextForMarkdown HTML-entity-encodes text (`<` → `<`) and backslash-escapes
|
||||
// ``` ` * _ [ ] ~ ``` — both corrupt our content (tags become entities, `[1-1000]`/`*.php`/`snake_case`
|
||||
// gain stray backslashes). Text serialization is hard-coded in the manager (no per-extension hook), so we
|
||||
// retune that one method: drop the entity-encoding entirely, and backslash-escape only the chars that
|
||||
// would otherwise re-parse as inline syntax (`` ` ``, `~`, `\`).
|
||||
const faithfulEscape = (text: string): string => text.replace(/([\\`~])/g, '\\$1');
|
||||
|
||||
type ManagerWithEncode = {
|
||||
codeTypes: Set<string>;
|
||||
encodeTextForMarkdown: (text: string, node: MarkdownNode, parentNode?: MarkdownNode) => string;
|
||||
};
|
||||
type MarkdownNode = { marks?: (string | { type: string })[]; text?: string; type?: string };
|
||||
|
||||
export const FaithfulMarkdownText = Extension.create({
|
||||
name: 'faithfulMarkdownText',
|
||||
onBeforeCreate() {
|
||||
const manager = this.editor.markdown as unknown as ManagerWithEncode | undefined;
|
||||
|
||||
if (!manager) {
|
||||
return;
|
||||
}
|
||||
|
||||
manager.encodeTextForMarkdown = function encodeTextForMarkdown(text, node, parentNode) {
|
||||
const isInsideCode =
|
||||
(parentNode?.type != null && this.codeTypes.has(parentNode.type)) ||
|
||||
(node.marks ?? []).some((mark) => this.codeTypes.has(typeof mark === 'string' ? mark : mark.type));
|
||||
|
||||
return isInsideCode ? text : faithfulEscape(text);
|
||||
};
|
||||
},
|
||||
// Lower priority than the Markdown extension so this runs AFTER its onBeforeCreate has created the
|
||||
// manager and assigned editor.markdown. onBeforeCreate (not onCreate) because it is synchronous —
|
||||
// a headless editor's onCreate fires after construction, too late for the first getMarkdown().
|
||||
priority: 50,
|
||||
});
|
||||
|
||||
// @tiptap/extension-table's renderTableToMarkdown is alignment-aware but never escapes pipes, so a literal
|
||||
// `|` in a cell (even inside inline code) re-parses as a column delimiter on the next save and drops cells
|
||||
// (tiptap PR #7884, still open). renderTableToMarkdown only emits cell content via h.renderChildren, so
|
||||
// wrapping that one call to escape pipes is enough — and it stays on the official, alignment-aware renderer.
|
||||
type RenderHelpers = { renderChildren: (nodes: JSONContent | JSONContent[], separator?: string) => string };
|
||||
|
||||
export const MarkdownTable = Table.extend({
|
||||
renderMarkdown(node: JSONContent, helpers: RenderHelpers) {
|
||||
const pipeEscaping: RenderHelpers = {
|
||||
...helpers,
|
||||
renderChildren: (nodes, separator) => helpers.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
|
||||
};
|
||||
|
||||
return renderTableToMarkdown(node, pipeEscaping as never);
|
||||
},
|
||||
});
|
||||
|
||||
// The official Markdown extension wired with our faithful `marked`, plus the serialize-side retune.
|
||||
// The `as never` bridges a transitive version skew: @tiptap/markdown's `marked` option is typed against
|
||||
// its own marked@17 while our direct dep is marked@18; the instance is runtime-compatible (corpus-verified).
|
||||
export const createMarkdownLayer = () => [
|
||||
Markdown.configure({ marked: createFaithfulMarked() as never }),
|
||||
FaithfulMarkdownText,
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
|
||||
// Highlights xml-like tags (<container_environment>, </language_policy>, <specialist name="x">)
|
||||
// as VIEW-ONLY decorations — same byte-neutral approach as editor-variable-highlight. A node would
|
||||
// have to tell a structural tag from a tag NAME quoted inline as documentation (the prompts do both,
|
||||
// in prose AND code), which can't be done reliably and risks corrupting the template on save; a
|
||||
// decoration colours any tag-shaped text wherever it appears and never touches the document.
|
||||
const tagHighlightKey = new PluginKey('tagHighlight');
|
||||
|
||||
// `[^<>]` for the attribute span keeps the scan linear and stops at the next angle bracket.
|
||||
export const TAG_RE = /<\/?[a-zA-Z][\w-]*(?:\s[^<>]*)?\/?>/g;
|
||||
|
||||
const buildDecorations = (doc: PMNode): DecorationSet => {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText || !node.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of node.text.matchAll(TAG_RE)) {
|
||||
const from = pos + (match.index ?? 0);
|
||||
|
||||
decorations.push(Decoration.inline(from, from + match[0].length, { class: 'template-tag' }));
|
||||
}
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
};
|
||||
|
||||
export const TagHighlight = Extension.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: tagHighlightKey,
|
||||
props: {
|
||||
decorations(state) {
|
||||
return tagHighlightKey.getState(state);
|
||||
},
|
||||
},
|
||||
state: {
|
||||
apply: (tr, old: DecorationSet) => (tr.docChanged ? buildDecorations(tr.doc) : old),
|
||||
init: (_config, { doc }) => buildDecorations(doc),
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
name: 'tagHighlight',
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
|
||||
// Highlights Go-template actions ({{.Var}}, {{- if .X}}, {{end}}, {{.A | upper}}, …) as
|
||||
// VIEW-ONLY decorations. Decorations never touch the document, so getMarkdown() stays
|
||||
// byte-identical — and {{ }} already round-trips verbatim (prosemirror-markdown esc()
|
||||
// escapes only ` * \ ~ [ ] _, tiptap-markdown escapeHTML only < >; neither touches { }).
|
||||
// A node/mark would gain nothing here and would break the variables side-panel, which
|
||||
// inserts {{.X}} as plain text and cycles uses by regex over the serialized string.
|
||||
const variableHighlightKey = new PluginKey('variableHighlight');
|
||||
|
||||
// `[^{}]` keeps the scan linear (no catastrophic backtracking); Go actions never nest braces.
|
||||
export const VARIABLE_RE = /\{\{[^{}]*\}\}/g;
|
||||
|
||||
// Scan per text node — NOT over doc.textContent — or inline positions misalign across blocks.
|
||||
const buildDecorations = (doc: PMNode): DecorationSet => {
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText || !node.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of node.text.matchAll(VARIABLE_RE)) {
|
||||
const from = pos + (match.index ?? 0);
|
||||
|
||||
decorations.push(Decoration.inline(from, from + match[0].length, { class: 'template-variable' }));
|
||||
}
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
};
|
||||
|
||||
export const VariableHighlight = Extension.create({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: variableHighlightKey,
|
||||
props: {
|
||||
decorations(state) {
|
||||
return variableHighlightKey.getState(state);
|
||||
},
|
||||
},
|
||||
state: {
|
||||
apply: (tr, old: DecorationSet) => (tr.docChanged ? buildDecorations(tr.doc) : old),
|
||||
init: (_config, { doc }) => buildDecorations(doc),
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
name: 'variableHighlight',
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createMarkdownExtensions } from './markdown-editor-extensions';
|
||||
|
||||
// The editor's Placeholder extension tracks the viewport via document.elementFromPoint +
|
||||
// Range.getClientRects, which jsdom lacks — stub them or every editor mount throws.
|
||||
beforeAll(() => {
|
||||
document.elementFromPoint = () => null;
|
||||
const r = { bottom: 0, height: 0, left: 0, right: 0, toJSON: () => ({}), top: 0, width: 0, x: 0, y: 0 };
|
||||
Range.prototype.getBoundingClientRect = () => r as DOMRect;
|
||||
Range.prototype.getClientRects = () =>
|
||||
({ item: () => null, length: 0, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
|
||||
});
|
||||
|
||||
// Node-fs corpus test: reads the real backend prompt templates (XML-tag-heavy Go templates) and asserts
|
||||
// the @tiptap/markdown round-trip preserves their CONTENT on EVERY one. Excluded from the app `tsc` build
|
||||
// (uses node APIs); validated at runtime by vitest.
|
||||
const roundTrip = (content: string): string => {
|
||||
const editor = new Editor({ content, contentType: 'markdown', extensions: createMarkdownExtensions() });
|
||||
const out = editor.getMarkdown();
|
||||
editor.destroy();
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const variables = (s: string) => s.match(/\{\{[^{}]*\}\}/g) ?? [];
|
||||
const words = (s: string) => s.match(/[\p{L}\p{N}]+/gu) ?? [];
|
||||
|
||||
describe('corpus — every real prompt .tmpl survives the round-trip with no content loss', () => {
|
||||
const dir = join(__dirname, '..', '..', '..', '..', 'backend', 'pkg', 'templates', 'prompts');
|
||||
const files = readdirSync(dir).filter((file) => file.endsWith('.tmpl'));
|
||||
|
||||
for (const file of files) {
|
||||
it(file + ': tags literal, {{ }} preserved, no word dropped, converges', () => {
|
||||
const src = readFileSync(join(dir, file), 'utf8');
|
||||
const save1 = roundTrip(src);
|
||||
const save2 = roundTrip(save1);
|
||||
|
||||
// tags stay literal — no HTML-entity escaping introduced.
|
||||
expect(save1).not.toContain('<');
|
||||
expect(save1).not.toContain('>');
|
||||
// every {{ }} action survives (set + order).
|
||||
expect(variables(save1)).toEqual(variables(src));
|
||||
// no source word is dropped (cosmetic whitespace reformatting aside).
|
||||
const after = new Set(words(save2));
|
||||
const lost = [...new Set(words(src))].filter((w) => !after.has(w));
|
||||
expect(lost).toEqual([]);
|
||||
// converges — the canonical form is stable on resave.
|
||||
expect(roundTrip(save2)).toBe(save2);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createMarkdownExtensions } from './markdown-editor-extensions';
|
||||
|
||||
beforeAll(() => {
|
||||
document.elementFromPoint = () => null;
|
||||
const r = { bottom: 0, height: 0, left: 0, right: 0, toJSON: () => ({}), top: 0, width: 0, x: 0, y: 0 };
|
||||
Range.prototype.getBoundingClientRect = () => r as DOMRect;
|
||||
Range.prototype.getClientRects = () =>
|
||||
({ item: () => null, length: 0, [Symbol.iterator]: [][Symbol.iterator] }) as unknown as DOMRectList;
|
||||
});
|
||||
|
||||
// Runs the EXACT production extension set (createMarkdownExtensions) through the official
|
||||
// @tiptap/markdown round-trip: markdown string -> marked parse -> ProseMirror doc -> getMarkdown().
|
||||
const roundTrip = (content: string): string => {
|
||||
const editor = new Editor({ content, contentType: 'markdown', extensions: createMarkdownExtensions() });
|
||||
const out = editor.getMarkdown();
|
||||
editor.destroy();
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const words = (s: string): string[] => s.match(/[\p{L}\p{N}]+/gu) ?? [];
|
||||
const sameWords = (a: string, b: string) => expect(words(b).sort()).toEqual(words(a).sort());
|
||||
|
||||
describe('literal <tags> survive (marked HTML-tokenizers neutralized + no entity-encode)', () => {
|
||||
it.each([
|
||||
'<container_environment>',
|
||||
'</language_policy>',
|
||||
'<specialist name="searcher">',
|
||||
// void HTML elements: marked would SWALLOW these without the neutralized html/tag tokenizers.
|
||||
'<input>',
|
||||
'<br>',
|
||||
])('keeps %s verbatim (no <, no swallow)', (tag) => {
|
||||
const out = roundTrip('lead ' + tag + ' tail');
|
||||
|
||||
expect(out).toContain(tag);
|
||||
expect(out).not.toContain('<');
|
||||
expect(out).not.toContain('>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go-template variables survive verbatim', () => {
|
||||
it.each(['{{.Var}}', '{{- if .X}}', '{{range .Items}}', '{{.X | upper}}', '{{printf "%d" .N}}', '{{- .Both -}}'])(
|
||||
'keeps %s',
|
||||
(v) => {
|
||||
expect(roundTrip('lead ' + v + ' tail')).toContain(v);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('selective escape — no stray backslashes on literal punctuation', () => {
|
||||
it.each(['Scan ports [1-1000].', 'run nmap *.php here', 'a snake_case_name and array[i] index'])(
|
||||
'leaves %s unescaped',
|
||||
(s) => {
|
||||
const out = roundTrip(s);
|
||||
|
||||
expect(out).not.toContain('\\[');
|
||||
expect(out).not.toContain('\\*');
|
||||
expect(out).not.toContain('\\_');
|
||||
sameWords(s, out);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('inline marks round-trip', () => {
|
||||
it.each(['**bold**', '*italic*', '`code span`', '~~strike~~', '[a link](https://example.com)', '**bold `code` end**'])(
|
||||
'preserves %s',
|
||||
(s) => {
|
||||
expect(roundTrip(s)).toContain(s);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('MarkdownTable — cell pipes escaped + alignment preserved, idempotent', () => {
|
||||
it.each([
|
||||
['pipe in a code-span cell', '| Op | Meaning |\n| --- | --- |\n| `\\|` | pipe op |\n| `\\|\\|` | or op |', 'pipe op'],
|
||||
['pipes inside inline code', '| Name | Payload |\n| --- | --- |\n| chain | `echo \\| base64 \\| sh` |', 'base64'],
|
||||
])('cell content survives two saves: %s', (_l, src, marker) => {
|
||||
const save1 = roundTrip(src);
|
||||
const save2 = roundTrip(save1);
|
||||
|
||||
expect(save2).toBe(save1);
|
||||
expect(save2).toContain(marker);
|
||||
});
|
||||
|
||||
it('preserves per-column alignment (left :--- / center :---: / right ---:)', () => {
|
||||
const save1 = roundTrip('| L | C | R |\n| :-- | :-: | --: |\n| a | b | c |');
|
||||
|
||||
// renderTableToMarkdown emits the alignment colons (dash count padded to column width, min 3).
|
||||
expect(save1).toContain('| :--- | :---: | ---: |');
|
||||
expect(roundTrip(save1)).toBe(save1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nesting & sequencing — content preserved and converges (≤2 saves)', () => {
|
||||
it.each([
|
||||
['bullet + code', '- item one\n- item two:\n\n ```\n code alpha\n ```\n- item three'],
|
||||
['ordered + code', '1. first\n2. second:\n\n ```python\n code beta\n ```\n3. third'],
|
||||
['nested bullets', '- outer aaa\n - inner bbb\n - deepest ccc'],
|
||||
['blockquote + list', '> - quoted alpha\n> - quoted beta'],
|
||||
['blockquote + code', '> intro\n>\n> ```\n> quoted code zeta\n> ```'],
|
||||
['task list nested', '- [ ] task alpha\n - [x] subtask beta\n- [x] task delta'],
|
||||
['heading > list > code', '## Title\n\n- item alpha\n- item beta\n\n```\nstandalone epsilon\n```'],
|
||||
['table > list > quote', '| A | B |\n| --- | --- |\n| 1 | 2 |\n\n- item one\n\n> quote alpha'],
|
||||
])('%s', (_l, src) => {
|
||||
const save1 = roundTrip(src);
|
||||
const save2 = roundTrip(save1);
|
||||
|
||||
// converges (canonicalizes once, then stable) and no word is dropped.
|
||||
expect(save2).toBe(save1);
|
||||
sameWords(src, save2);
|
||||
});
|
||||
});
|
||||
|
||||
// KNOWN BUG (marked parser, not our customizations — vanilla @tiptap/markdown repros it). A code block
|
||||
// nested in a bullet sublist that is itself inside an ordered list is SILENTLY DROPPED. markdown-it kept
|
||||
// it. 0 corpus docs hit it. This test pins the CURRENT (buggy) behavior so we notice if an upstream
|
||||
// @tiptap/markdown release fixes it (then flip it to a passing round-trip + drop this note).
|
||||
describe('KNOWN UPSTREAM BUG — ordered > bullet > code drops the code block', () => {
|
||||
it('still loses the deeply-nested code (remove this test once @tiptap/markdown fixes it)', () => {
|
||||
const out = roundTrip('1. lvl1\n - lvl2\n\n ```\n deepcode\n ```');
|
||||
|
||||
expect(out).not.toContain('deepcode');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
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 './editor-markdown';
|
||||
import { TagHighlight } from './editor-tag-highlight';
|
||||
import { VariableHighlight } from './editor-variable-highlight';
|
||||
|
||||
// Single source of truth for the editor's extension stack — shared by markdown-editor.tsx AND the
|
||||
// round-trip tests so they can never drift. createMarkdownLayer is the official @tiptap/markdown layer
|
||||
// tuned for our content (see editor-markdown.ts); VariableHighlight/TagHighlight are view-only decorations
|
||||
// ({{vars}} / <tags>) that don't affect serialization.
|
||||
export const createMarkdownExtensions = (placeholder?: string) => [
|
||||
StarterKit.configure({ codeBlock: { HTMLAttributes: { class: 'hljs' } } }),
|
||||
MarkdownTable.configure({ resizable: true }),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem.configure({ nested: true }),
|
||||
Image,
|
||||
VariableHighlight,
|
||||
TagHighlight,
|
||||
Placeholder.configure({ emptyEditorClass: 'is-editor-empty', placeholder }),
|
||||
...createMarkdownLayer(),
|
||||
];
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
import { Placeholder } from '@tiptap/extensions';
|
||||
import { history } from '@tiptap/pm/history';
|
||||
import { EditorState } from '@tiptap/pm/state';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
@@ -12,26 +10,31 @@ import {
|
||||
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, useRef } from 'react';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { createMarkdownExtensions } from './markdown-editor-extensions';
|
||||
|
||||
export interface MarkdownEditorHandle {
|
||||
focus: () => void;
|
||||
getEditor: () => Editor | null;
|
||||
insertAtCursor: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
@@ -115,9 +118,9 @@ function MarkdownEditor({
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
// Tracks the last markdown the editor reported externally. We compare
|
||||
// against this to suppress echo updates: tiptap-markdown can re-serialize
|
||||
// content slightly differently than the input string (whitespace/list
|
||||
// markers/hard breaks/etc.), and we don't want to flag those
|
||||
// against this to suppress echo updates: the markdown round-trip can
|
||||
// re-serialize content slightly differently than the input string
|
||||
// (whitespace/list markers/blank lines/etc.), and we don't want to flag those
|
||||
// normalizations as user edits — that would falsely flip RHF's
|
||||
// `isDirty` flag.
|
||||
//
|
||||
@@ -146,33 +149,13 @@ function MarkdownEditor({
|
||||
|
||||
const editor = useEditor({
|
||||
content: value,
|
||||
contentType: 'markdown',
|
||||
editable: !disabled,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: { HTMLAttributes: { class: 'hljs' } },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
emptyEditorClass: 'is-editor-empty',
|
||||
placeholder,
|
||||
}),
|
||||
Markdown.configure({
|
||||
breaks: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
tightLists: true,
|
||||
transformCopiedText: true,
|
||||
// Plain text pasted from the OS clipboard is left as-is.
|
||||
// With `transformPastedText: true`, a leading "- " (or
|
||||
// "1. ", "> ", etc.) would be parsed as markdown and
|
||||
// turn the paste into a list/blockquote — almost never
|
||||
// what the user wants for knowledge documents.
|
||||
transformPastedText: false,
|
||||
}),
|
||||
],
|
||||
extensions: createMarkdownExtensions(placeholder),
|
||||
immediatelyRender: false,
|
||||
onBlur: () => onBlurRef.current?.(),
|
||||
onCreate: ({ editor: instance }) => {
|
||||
lastEmittedRef.current = instance.storage.markdown.getMarkdown();
|
||||
lastEmittedRef.current = instance.getMarkdown();
|
||||
isInitializedRef.current = true;
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
@@ -180,7 +163,7 @@ function MarkdownEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
const next = instance.storage.markdown.getMarkdown();
|
||||
const next = instance.getMarkdown();
|
||||
|
||||
if (next === lastEmittedRef.current) {
|
||||
return;
|
||||
@@ -196,6 +179,16 @@ function MarkdownEditor({
|
||||
() => ({
|
||||
focus: () => editor?.commands.focus(),
|
||||
getEditor: () => editor,
|
||||
insertAtCursor: (text: string) => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { state, view } = editor;
|
||||
|
||||
view.dispatch(state.tr.insertText(text).scrollIntoView());
|
||||
view.focus();
|
||||
},
|
||||
}),
|
||||
[editor],
|
||||
);
|
||||
@@ -207,11 +200,17 @@ function MarkdownEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
const current = editor.storage.markdown.getMarkdown();
|
||||
const current = editor.getMarkdown();
|
||||
const shouldExternalSync = current !== value;
|
||||
|
||||
if (shouldExternalSync) {
|
||||
editor.commands.setContent(value, { emitUpdate: false });
|
||||
// @tiptap/markdown only wires `contentType: 'markdown'` into the initial
|
||||
// content + insertContent — NOT setContent — so parse the markdown explicitly.
|
||||
const parsed = editor.markdown?.parse(value);
|
||||
|
||||
if (parsed) {
|
||||
editor.commands.setContent(parsed, { emitUpdate: false });
|
||||
}
|
||||
}
|
||||
|
||||
// The editor's own serialized markdown is the canonical form —
|
||||
@@ -219,7 +218,7 @@ function MarkdownEditor({
|
||||
// representation, not the (possibly non-normalized) input
|
||||
// `value`. Storing the raw `value` here would cause the first
|
||||
// user keystroke to also re-emit the round-trip normalization.
|
||||
lastEmittedRef.current = editor.storage.markdown.getMarkdown();
|
||||
lastEmittedRef.current = editor.getMarkdown();
|
||||
|
||||
// Clear the undo stack:
|
||||
// - on initial mount, to discard the construction-time
|
||||
@@ -422,6 +421,15 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps)
|
||||
>
|
||||
<ListOrdered />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Task list"
|
||||
onPressedChange={() => editor.chain().focus().toggleTaskList().run()}
|
||||
pressed={editor.isActive('taskList')}
|
||||
size="sm"
|
||||
title="Task list"
|
||||
>
|
||||
<ListTodo />
|
||||
</Toggle>
|
||||
|
||||
<Separator
|
||||
className="mx-1 h-5"
|
||||
@@ -455,6 +463,21 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps)
|
||||
>
|
||||
<LinkIcon />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Insert image"
|
||||
onPressedChange={() => {
|
||||
const url = window.prompt('Image URL');
|
||||
|
||||
if (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()}
|
||||
@@ -464,6 +487,17 @@ function MarkdownEditorToolbar({ disabled, editor }: MarkdownEditorToolbarProps)
|
||||
>
|
||||
<Minus />
|
||||
</Toggle>
|
||||
<Toggle
|
||||
aria-label="Insert table"
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: true }).run()
|
||||
}
|
||||
pressed={editor.isActive('table')}
|
||||
size="sm"
|
||||
title="Insert table"
|
||||
>
|
||||
<Table />
|
||||
</Toggle>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<Toggle
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { InputGroup, InputGroupTextareaAutosize } from '@/components/ui/input-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { KnowledgeAnswerType, KnowledgeDocType, KnowledgeGuideType } from '@/graphql/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { FormValues } from './knowledge-form';
|
||||
|
||||
@@ -74,6 +76,8 @@ interface KnowledgeContentFieldProps {
|
||||
fillParent?: boolean;
|
||||
isSaving: boolean;
|
||||
showLabel?: boolean;
|
||||
/** 'visual' (default) = rich MarkdownEditor; 'plain' = raw-markdown textarea. */
|
||||
viewMode?: 'plain' | 'visual';
|
||||
}
|
||||
|
||||
interface KnowledgeMetaFieldsProps {
|
||||
@@ -87,6 +91,7 @@ export function KnowledgeContentField({
|
||||
fillParent = false,
|
||||
isSaving,
|
||||
showLabel = false,
|
||||
viewMode = 'visual',
|
||||
}: KnowledgeContentFieldProps) {
|
||||
return (
|
||||
<FormField
|
||||
@@ -96,15 +101,30 @@ export function KnowledgeContentField({
|
||||
<FormItem className={fillParent ? 'flex min-h-0 flex-1 flex-col' : undefined}>
|
||||
{showLabel ? <FormLabel>Content</FormLabel> : null}
|
||||
<FormControl>
|
||||
<MarkdownEditor
|
||||
className={fillParent ? 'min-h-0 flex-1' : 'min-h-[280px]'}
|
||||
contentClassName={fillParent ? undefined : 'min-h-[240px]'}
|
||||
disabled={isSaving}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
placeholder="Knowledge content (will be embedded into the vector store)"
|
||||
value={field.value}
|
||||
/>
|
||||
{viewMode === 'plain' ? (
|
||||
<Textarea
|
||||
autoSize={false}
|
||||
className={cn(
|
||||
'resize-none font-mono text-sm',
|
||||
fillParent ? 'min-h-0 flex-1' : 'min-h-[280px]',
|
||||
)}
|
||||
disabled={isSaving}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
placeholder="Knowledge content (will be embedded into the vector store)"
|
||||
value={field.value}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownEditor
|
||||
className={fillParent ? 'min-h-0 flex-1' : 'min-h-[280px]'}
|
||||
contentClassName={fillParent ? undefined : 'min-h-[240px]'}
|
||||
disabled={isSaving}
|
||||
onBlur={field.onBlur}
|
||||
onChange={field.onChange}
|
||||
placeholder="Knowledge content (will be embedded into the vector store)"
|
||||
value={field.value}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -17,6 +17,7 @@ interface KnowledgeFormLayoutProps {
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
knowledge?: KnowledgeDocumentFragmentFragment | null;
|
||||
viewMode?: 'plain' | 'visual';
|
||||
}
|
||||
|
||||
interface KnowledgeIntroBlockProps {
|
||||
@@ -24,7 +25,7 @@ interface KnowledgeIntroBlockProps {
|
||||
knowledge?: KnowledgeDocumentFragmentFragment | null;
|
||||
}
|
||||
|
||||
export function KnowledgeFormLayoutDesktop({ control, isNew, isSaving, knowledge }: KnowledgeFormLayoutProps) {
|
||||
export function KnowledgeFormLayoutDesktop({ control, isNew, isSaving, knowledge, viewMode }: KnowledgeFormLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-0 w-full max-w-full flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
@@ -63,6 +64,7 @@ export function KnowledgeFormLayoutDesktop({ control, isNew, isSaving, knowledge
|
||||
control={control}
|
||||
fillParent
|
||||
isSaving={isSaving}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
@@ -71,7 +73,7 @@ export function KnowledgeFormLayoutDesktop({ control, isNew, isSaving, knowledge
|
||||
);
|
||||
}
|
||||
|
||||
export function KnowledgeFormLayoutMobile({ control, isNew, isSaving, knowledge }: KnowledgeFormLayoutProps) {
|
||||
export function KnowledgeFormLayoutMobile({ control, isNew, isSaving, knowledge, viewMode }: KnowledgeFormLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 p-4">
|
||||
<KnowledgeIntroBlock
|
||||
@@ -87,6 +89,7 @@ export function KnowledgeFormLayoutMobile({ control, isNew, isSaving, knowledge
|
||||
control={control}
|
||||
isSaving={isSaving}
|
||||
showLabel
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -198,6 +198,7 @@ export function KnowledgeForm({ initialValues, isNew, knowledge, onSubmit }: Kno
|
||||
const { isDesktop } = useBreakpoint();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isAnonymizing, setIsAnonymizing] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'plain' | 'visual'>('visual');
|
||||
const [anonymizeMutation] = useMutation(AnonymizeTextDocument);
|
||||
const { authInfo } = useUser();
|
||||
const canAnonymize = authInfo?.privileges?.includes('anonymize.call') ?? false;
|
||||
@@ -390,7 +391,9 @@ export function KnowledgeForm({ initialValues, isNew, knowledge, onSubmit }: Kno
|
||||
knowledge={knowledge}
|
||||
onAnonymize={handleAnonymize}
|
||||
onBeforeNavigateAway={skipNextBlock}
|
||||
onToggleViewMode={() => setViewMode((m) => (m === 'visual' ? 'plain' : 'visual'))}
|
||||
saveButton={saveButton}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
{isDesktop ? (
|
||||
<KnowledgeFormLayoutDesktop
|
||||
@@ -398,6 +401,7 @@ export function KnowledgeForm({ initialValues, isNew, knowledge, onSubmit }: Kno
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
knowledge={knowledge}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
) : (
|
||||
<KnowledgeFormLayoutMobile
|
||||
@@ -405,6 +409,7 @@ export function KnowledgeForm({ initialValues, isNew, knowledge, onSubmit }: Kno
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
knowledge={knowledge}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Ellipsis, HatGlasses, LibraryBig, Loader2, Pencil, Trash } from 'lucide-react';
|
||||
import { Code, Ellipsis, Eye, HatGlasses, LibraryBig, Loader2, Pencil, Trash } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
@@ -52,7 +52,9 @@ interface KnowledgeHeaderProps {
|
||||
*/
|
||||
onAnonymize?: () => void;
|
||||
onBeforeNavigateAway?: () => void;
|
||||
onToggleViewMode?: () => void;
|
||||
saveButton?: ReactNode;
|
||||
viewMode?: 'plain' | 'visual';
|
||||
}
|
||||
|
||||
const renderKnowledgeItem = (item: Knowledge, isCurrent: boolean): ReactNode => (
|
||||
@@ -75,7 +77,9 @@ export function KnowledgeHeader({
|
||||
knowledge,
|
||||
onAnonymize,
|
||||
onBeforeNavigateAway,
|
||||
onToggleViewMode,
|
||||
saveButton,
|
||||
viewMode = 'visual',
|
||||
}: KnowledgeHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const { isMobile } = useBreakpoint();
|
||||
@@ -273,6 +277,16 @@ export function KnowledgeHeader({
|
||||
<Pencil className="size-3" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
{onToggleViewMode ? (
|
||||
<DropdownMenuItem onClick={onToggleViewMode}>
|
||||
{viewMode === 'plain' ? (
|
||||
<Eye className="size-4" />
|
||||
) : (
|
||||
<Code className="size-4" />
|
||||
)}
|
||||
{viewMode === 'plain' ? 'Visual editor' : 'Plain text'}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={isDeleting}
|
||||
|
||||
@@ -18,17 +18,7 @@ import {
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ComponentProps,
|
||||
lazy,
|
||||
type Ref,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { type ComponentProps, lazy, type Ref, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ReactDiffViewer from 'react-diff-viewer-continued';
|
||||
import {
|
||||
type Control,
|
||||
@@ -52,7 +42,7 @@ import type {
|
||||
type AgentPrompt = AgentPrompts;
|
||||
type AgentPrompts = { human?: DefaultPrompt; system: DefaultPrompt };
|
||||
|
||||
import type { ReactCodeMirrorRef } from '@/components/shared/code-editor';
|
||||
import type { MarkdownEditorHandle } from '@/components/shared/markdown-editor';
|
||||
|
||||
import {
|
||||
AppHeader,
|
||||
@@ -92,9 +82,9 @@ import { useBreakpoint } from '@/hooks/use-breakpoint';
|
||||
import { formatPromptId } from '@/lib/route-titles/format-prompt-id';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Dynamic-only import: a static CodeEditor import would merge its ~600KB CodeMirror chunk into this route bundle.
|
||||
const CodeEditor = lazy(() =>
|
||||
import('@/components/shared/code-editor').then((module) => ({ default: module.CodeEditor })),
|
||||
// Dynamic-only import: a static import would merge the tiptap editor chunk into this route bundle.
|
||||
const MarkdownEditor = lazy(() =>
|
||||
import('@/components/shared/markdown-editor').then((module) => ({ default: module.MarkdownEditor })),
|
||||
);
|
||||
|
||||
const systemFormSchema = z.object({
|
||||
@@ -120,7 +110,7 @@ interface ControllerProps<T extends FieldValues> {
|
||||
}
|
||||
|
||||
interface FormCodeItemProps<T extends FieldValues> extends ControllerProps<T> {
|
||||
editorRef?: Ref<ReactCodeMirrorRef>;
|
||||
editorRef?: Ref<MarkdownEditorHandle>;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
@@ -155,7 +145,7 @@ function FormCodeItem<T extends FieldValues>({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CodeEditor
|
||||
<MarkdownEditor
|
||||
className="min-h-0 flex-1"
|
||||
disabled={disabled}
|
||||
onBlur={field.onBlur}
|
||||
@@ -312,26 +302,15 @@ function SettingsPrompt() {
|
||||
const [validationResult, setValidationResult] = useState<null | ValidatePromptMutation['validatePrompt']>(null);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const [isDiffDialogOpen, setIsDiffDialogOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'code' | 'plain'>('plain');
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const [viewMode, setViewMode] = useState<'code' | 'plain'>('code');
|
||||
const editorRef = useRef<MarkdownEditorHandle>(null);
|
||||
|
||||
const isLoading = isCreateLoading || isUpdateLoading || isDeleteLoading || isValidateLoading;
|
||||
|
||||
const handleVariableClick = useCallback(
|
||||
(variable: string, field: { onChange: (value: string) => void; value: string }, formId: string) => {
|
||||
if (viewMode === 'code') {
|
||||
const view = editorRef.current?.view;
|
||||
|
||||
if (view) {
|
||||
const insert = `{{.${variable}}}`;
|
||||
const position = view.state.selection.main.head;
|
||||
view.dispatch({
|
||||
changes: { from: position, insert },
|
||||
scrollIntoView: true,
|
||||
selection: { anchor: position + insert.length },
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
editorRef.current?.insertAtCursor(`{{.${variable}}}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -55,9 +55,8 @@ import { routes } from '@/lib/routes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Template, useTemplates } from '@/providers/templates-provider';
|
||||
|
||||
// Dynamic-only import: a static CodeEditor import would merge its ~600KB CodeMirror chunk into this route bundle.
|
||||
const CodeEditor = lazy(() =>
|
||||
import('@/components/shared/code-editor').then((module) => ({ default: module.CodeEditor })),
|
||||
const MarkdownEditor = lazy(() =>
|
||||
import('@/components/shared/markdown-editor').then((module) => ({ default: module.MarkdownEditor })),
|
||||
);
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -268,7 +267,7 @@ function Template() {
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'code' | 'plain'>('plain');
|
||||
const [viewMode, setViewMode] = useState<'code' | 'plain'>('code');
|
||||
|
||||
const {
|
||||
handleDropdownCloseAutoFocus,
|
||||
@@ -650,7 +649,7 @@ function Template() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CodeEditor
|
||||
<MarkdownEditor
|
||||
className="min-h-0 flex-1"
|
||||
disabled={isSaving}
|
||||
onBlur={field.onBlur}
|
||||
|
||||
@@ -632,3 +632,107 @@
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
margin: 0.75rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror th,
|
||||
.tiptap-content .ProseMirror td {
|
||||
position: relative;
|
||||
min-width: 4rem;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.375rem 0.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror th {
|
||||
background-color: var(--muted);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror th > *,
|
||||
.tiptap-content .ProseMirror td > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Selected-cell overlay + column resize handle (prosemirror-tables) */
|
||||
.tiptap-content .ProseMirror .selectedCell::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in oklab, var(--primary) 15%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror .column-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
width: 4px;
|
||||
background-color: var(--primary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror.resize-cursor {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror ul[data-type='taskList'] li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror ul[data-type='taskList'] li > label {
|
||||
margin-top: 0.2rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror ul[data-type='taskList'] li > div {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror ul[data-type='taskList'] li > div > p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
.tiptap-content .ProseMirror img.ProseMirror-selectednode {
|
||||
outline: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
/* `{{ ... }}` Go-template actions, highlighted via view-only decorations (document bytes untouched). */
|
||||
.tiptap-content .ProseMirror .template-variable {
|
||||
color: var(--primary);
|
||||
background-color: color-mix(in oklab, var(--primary) 12%, transparent);
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
/* `<xml-tags>`, highlighted view-only. The theme palette is monochrome blue (hue 245), so rotate
|
||||
the hue off primary to a teal/green that reads distinct from the blue variables, in both themes. */
|
||||
.tiptap-content .ProseMirror .template-tag {
|
||||
--tag-color: oklch(from var(--primary) l c calc(h - 90));
|
||||
color: var(--tag-color);
|
||||
background-color: color-mix(in oklab, var(--tag-color) 12%, transparent);
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
@@ -27,5 +27,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "types/**/*.d.ts"]
|
||||
"include": ["src", "types/**/*.d.ts"],
|
||||
"exclude": ["src/**/*-corpus.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user