@puredesktop/platform-editor 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +63 -0
  2. package/src/CollapsePanel.tsx +35 -0
  3. package/src/DocumentDiffView.tsx +130 -0
  4. package/src/DocumentEditor.test.ts +118 -0
  5. package/src/DocumentEditor.tsx +2631 -0
  6. package/src/alignedLineDiff.test.ts +52 -0
  7. package/src/alignedLineDiff.ts +67 -0
  8. package/src/collectionAssetSrc.ts +2 -0
  9. package/src/commentUtils.test.ts +350 -0
  10. package/src/commentUtils.ts +546 -0
  11. package/src/constants/toolKeys.ts +14 -0
  12. package/src/contentFormat.test.ts +27 -0
  13. package/src/contentFormat.ts +18 -0
  14. package/src/editorExtensions.ts +155 -0
  15. package/src/extensions/appliedChangeMark.ts +115 -0
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -0
  17. package/src/extensions/autoReviewPrompts.ts +1442 -0
  18. package/src/extensions/collectionImage.ts +26 -0
  19. package/src/extensions/collectionImagePaste.ts +215 -0
  20. package/src/extensions/commentMark.ts +223 -0
  21. package/src/extensions/documentAssetIdentity.ts +54 -0
  22. package/src/extensions/figure.ts +92 -0
  23. package/src/extensions/footnote.ts +186 -0
  24. package/src/extensions/indexMarker.ts +88 -0
  25. package/src/extensions/mathEditing.ts +239 -0
  26. package/src/extensions/mermaidBlock.tsx +297 -0
  27. package/src/extensions/slashCommands.test.ts +170 -0
  28. package/src/extensions/slashCommands.ts +746 -0
  29. package/src/extensions/smartTypography.test.ts +74 -0
  30. package/src/extensions/smartTypography.ts +120 -0
  31. package/src/index.ts +137 -0
  32. package/src/insertCollectionImage.test.ts +60 -0
  33. package/src/insertCollectionImage.ts +63 -0
  34. package/src/insertInlineAssetKind.test.ts +67 -0
  35. package/src/insertInlineAssetKind.ts +49 -0
  36. package/src/mermaidPreview.test.ts +54 -0
  37. package/src/mermaidPreview.ts +115 -0
  38. package/src/styled.ts +676 -0
  39. package/src/toolbar/ToolBtn.tsx +63 -0
  40. package/src/toolbar/Toolbar.test.tsx +325 -0
  41. package/src/toolbar/Toolbar.tsx +624 -0
  42. package/src/toolbar/groups/HeadingGroup.tsx +153 -0
  43. package/src/toolbar/overlayTypes.ts +28 -0
  44. package/src/useEditorExtensions.ts +22 -0
  45. package/src/utils/markdownUtils.ts +331 -0
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { SmartTypographyRules } from './smartTypography.js'
3
+
4
+ describe('SmartTypographyRules', () => {
5
+ describe('emDash', () => {
6
+ it('replaces -- at end of input with em-dash', () => {
7
+ expect(SmartTypographyRules.emDash('she said--')).toBe('she said—')
8
+ })
9
+ it('does not touch a single dash', () => {
10
+ expect(SmartTypographyRules.emDash('she said-')).toBe('she said-')
11
+ })
12
+ })
13
+
14
+ describe('ellipsis', () => {
15
+ it('replaces ... with …', () => {
16
+ expect(SmartTypographyRules.ellipsis('then...')).toBe('then…')
17
+ })
18
+ it('does not touch two dots', () => {
19
+ expect(SmartTypographyRules.ellipsis('then..')).toBe('then..')
20
+ })
21
+ })
22
+
23
+ describe('curly double quotes', () => {
24
+ it('opens left double quote at start of input', () => {
25
+ expect(SmartTypographyRules.leftDouble('"')).toBe('“')
26
+ })
27
+ it('opens left double quote after whitespace', () => {
28
+ expect(SmartTypographyRules.leftDouble('he said "')).toBe('he said “')
29
+ })
30
+ it('closes right double quote after a non-space char', () => {
31
+ expect(SmartTypographyRules.rightDouble('done"')).toBe('done”')
32
+ })
33
+ })
34
+
35
+ describe('apostrophe', () => {
36
+ it("replaces straight apostrophe between letters (don't)", () => {
37
+ expect(SmartTypographyRules.apostrophe("don'")).toBe('don’')
38
+ })
39
+ it("preserves contractions like it's, can't, you're", () => {
40
+ expect(SmartTypographyRules.apostrophe("it'")).toBe('it’')
41
+ expect(SmartTypographyRules.apostrophe("can'")).toBe('can’')
42
+ expect(SmartTypographyRules.apostrophe("you'")).toBe('you’')
43
+ })
44
+ })
45
+
46
+ describe('curly single quotes', () => {
47
+ it('opens left single after whitespace', () => {
48
+ expect(SmartTypographyRules.leftSingle("she said '")).toBe('she said ‘')
49
+ })
50
+ it('opens left single at start', () => {
51
+ expect(SmartTypographyRules.leftSingle("'")).toBe('‘')
52
+ })
53
+ it('closes right single after sentence punctuation', () => {
54
+ expect(SmartTypographyRules.rightSingle("done.'")).toBe('done.’')
55
+ })
56
+ })
57
+
58
+ describe('collapseDoubleSpace', () => {
59
+ it('collapses double space after period', () => {
60
+ expect(SmartTypographyRules.collapseDoubleSpace('done. ')).toBe('done. ')
61
+ })
62
+ it('collapses double space after question mark', () => {
63
+ expect(SmartTypographyRules.collapseDoubleSpace('really? ')).toBe(
64
+ 'really? ',
65
+ )
66
+ })
67
+ it('collapses double space after exclamation', () => {
68
+ expect(SmartTypographyRules.collapseDoubleSpace('wow! ')).toBe('wow! ')
69
+ })
70
+ it('does not collapse double space after a non-sentence char', () => {
71
+ expect(SmartTypographyRules.collapseDoubleSpace('foo, ')).toBe('foo, ')
72
+ })
73
+ })
74
+ })
@@ -0,0 +1,120 @@
1
+ import { Extension } from '@tiptap/core'
2
+ import { InputRule } from '@tiptap/core'
3
+
4
+ /**
5
+ * Smart typography (#245).
6
+ *
7
+ * On-type transforms via TipTap InputRules:
8
+ * - `--` → em-dash
9
+ * - `...` → ellipsis
10
+ * - `"X` after a boundary → left double curly quote
11
+ * - `X"` → right double curly quote
12
+ * - `'` between letters → typographic apostrophe (right single curly)
13
+ * - ` 'X` → left single curly
14
+ * - `X'` → right single curly
15
+ * - `. `, `! `, `? ` → collapse double space after sentence punct
16
+ *
17
+ * InputRules are TipTap-native and are skipped automatically inside
18
+ * code marks and code blocks. Pasted text and existing content are
19
+ * not touched — only typed input.
20
+ */
21
+
22
+ const emDashRule = new InputRule({
23
+ find: /--$/,
24
+ handler: ({ state, range }) => {
25
+ state.tr.replaceWith(range.from, range.to, state.schema.text('—'))
26
+ },
27
+ })
28
+
29
+ const ellipsisRule = new InputRule({
30
+ find: /\.\.\.$/,
31
+ handler: ({ state, range }) => {
32
+ state.tr.replaceWith(range.from, range.to, state.schema.text('…'))
33
+ },
34
+ })
35
+
36
+ // Left double quote: a `"` after start-of-input or whitespace.
37
+ const leftDoubleRule = new InputRule({
38
+ find: /(^|[\s([{<])"$/,
39
+ handler: ({ state, range, match }) => {
40
+ const before = match[1]
41
+ state.tr.replaceWith(range.from, range.to, state.schema.text(before + '“'))
42
+ },
43
+ })
44
+
45
+ // Right double quote: a `"` directly after a non-space.
46
+ const rightDoubleRule = new InputRule({
47
+ find: /(\S)"$/,
48
+ handler: ({ state, range, match }) => {
49
+ const before = match[1]
50
+ state.tr.replaceWith(range.from, range.to, state.schema.text(before + '”'))
51
+ },
52
+ })
53
+
54
+ // Apostrophe inside a word (don't, can't, it's).
55
+ const apostropheRule = new InputRule({
56
+ find: /([A-Za-z])'$/,
57
+ handler: ({ state, range, match }) => {
58
+ const before = match[1]
59
+ state.tr.replaceWith(range.from, range.to, state.schema.text(before + '’'))
60
+ },
61
+ })
62
+
63
+ // Left single quote: a `'` after start-of-input or whitespace.
64
+ const leftSingleRule = new InputRule({
65
+ find: /(^|[\s([{<])'$/,
66
+ handler: ({ state, range, match }) => {
67
+ const before = match[1]
68
+ state.tr.replaceWith(range.from, range.to, state.schema.text(before + '‘'))
69
+ },
70
+ })
71
+
72
+ // Right single quote / apostrophe after non-letter non-space (e.g. `?').
73
+ const rightSingleRule = new InputRule({
74
+ find: /([!?.,;:)])'$/,
75
+ handler: ({ state, range, match }) => {
76
+ const before = match[1]
77
+ state.tr.replaceWith(range.from, range.to, state.schema.text(before + '’'))
78
+ },
79
+ })
80
+
81
+ // Double space after sentence punctuation → single.
82
+ const collapseDoubleSpaceRule = new InputRule({
83
+ find: /([.!?]) $/,
84
+ handler: ({ state, range, match }) => {
85
+ const punct = match[1]
86
+ state.tr.replaceWith(range.from, range.to, state.schema.text(punct + ' '))
87
+ },
88
+ })
89
+
90
+ export const SmartTypography = Extension.create({
91
+ name: 'smartTypography',
92
+ addInputRules() {
93
+ return [
94
+ emDashRule,
95
+ ellipsisRule,
96
+ leftDoubleRule,
97
+ rightDoubleRule,
98
+ apostropheRule,
99
+ leftSingleRule,
100
+ rightSingleRule,
101
+ collapseDoubleSpaceRule,
102
+ ]
103
+ },
104
+ })
105
+
106
+ /**
107
+ * Pure transformations exposed for unit-testing the rules without a
108
+ * TipTap editor instance. The functions apply the same regexes as the
109
+ * input rules to a string suffix and return the transformed suffix.
110
+ */
111
+ export const SmartTypographyRules = {
112
+ emDash: (s: string): string => s.replace(/--$/, '—'),
113
+ ellipsis: (s: string): string => s.replace(/\.\.\.$/, '…'),
114
+ leftDouble: (s: string): string => s.replace(/(^|[\s([{<])"$/, '$1“'),
115
+ rightDouble: (s: string): string => s.replace(/(\S)"$/, '$1”'),
116
+ apostrophe: (s: string): string => s.replace(/([A-Za-z])'$/, '$1’'),
117
+ leftSingle: (s: string): string => s.replace(/(^|[\s([{<])'$/, '$1‘'),
118
+ rightSingle: (s: string): string => s.replace(/([!?.,;:)])'$/, '$1’'),
119
+ collapseDoubleSpace: (s: string): string => s.replace(/([.!?]) $/, '$1 '),
120
+ }
package/src/index.ts ADDED
@@ -0,0 +1,137 @@
1
+ export {
2
+ DocumentEditor,
3
+ type DocumentEditorHandle,
4
+ type DocumentEditorProps,
5
+ } from './DocumentEditor.js'
6
+ export {
7
+ buildAlignedLineDiff,
8
+ type AlignedDiffLine,
9
+ type AlignedDiffLineKind,
10
+ type AlignedLineDiffResult,
11
+ } from './alignedLineDiff.js'
12
+ export {
13
+ DocumentDiffView,
14
+ type DocumentDiffViewProps,
15
+ } from './DocumentDiffView.js'
16
+ export {
17
+ buildExtensions,
18
+ type BuildExtensionsOptions,
19
+ type EditorFeatures,
20
+ } from './editorExtensions.js'
21
+ export { useEditorExtensions } from './useEditorExtensions.js'
22
+ export {
23
+ resolveEditorContent,
24
+ type EditorInputFormat,
25
+ } from './contentFormat.js'
26
+ export { toHtml, toMd } from './utils/markdownUtils.js'
27
+ export {
28
+ insertInlineAssetKind,
29
+ type InlineAssetInsertKind,
30
+ } from './insertInlineAssetKind.js'
31
+ export {
32
+ MathEditing,
33
+ openMathEditor,
34
+ insertEditableInlineMath,
35
+ insertEditableBlockMath,
36
+ } from './extensions/mathEditing.js'
37
+ export { COLLECTION_ASSET_SRC_ATTR } from './collectionAssetSrc.js'
38
+ export { CollectionImage } from './extensions/collectionImage.js'
39
+ export {
40
+ createCollectionImagePasteExtension,
41
+ type CollectionImagePasteExtensionOptions,
42
+ type CollectionImagePastePayload,
43
+ } from './extensions/collectionImagePaste.js'
44
+ export {
45
+ insertCollectionImage,
46
+ insertCollectionImageFromBinaryResult,
47
+ type InsertCollectionImageOptions,
48
+ uint8ArrayToBase64,
49
+ } from './insertCollectionImage.js'
50
+ export type {
51
+ EditorCommentPromptProps,
52
+ EditorLinkPromptProps,
53
+ EditorToolbarOverlays,
54
+ } from './toolbar/overlayTypes.js'
55
+
56
+ export { CommentMark } from './extensions/commentMark.js'
57
+ export { AppliedChangeMark } from './extensions/appliedChangeMark.js'
58
+ export {
59
+ AutoReviewPrompts,
60
+ AUTO_CLAIM_REVIEW_PROMPT,
61
+ AUTO_FACT_CHECK_REVIEW_PROMPT,
62
+ EDITORIAL_FRAMEWORK,
63
+ applyAutoReviewFindings,
64
+ buildAutoReviewPromptBundle,
65
+ buildAutoReviewPrompt,
66
+ collectAutoReviewParagraphs,
67
+ collectScopedAutoReviewParagraphs,
68
+ copyAutoReviewPromptsToClipboard,
69
+ enabledEditorialTypesForProcess,
70
+ parseAutoReviewFindingResponse,
71
+ runAutoReview,
72
+ validateAutoReviewFindings,
73
+ type AppliedAutoReviewFinding,
74
+ type ApplyAutoReviewFindingsOptions,
75
+ type AutoReviewFindingInput,
76
+ type AutoReviewFindingsProvider,
77
+ type AutoReviewFindingsProviderRequest,
78
+ type AutoReviewFindingResponse,
79
+ type AutoReviewFindingSeverity,
80
+ type AutoReviewParagraph,
81
+ type AutoReviewPromptKind,
82
+ type CopyAutoReviewPromptsResult,
83
+ type EditorialAnnotationType,
84
+ type EditorialDensity,
85
+ type EditorialFrameworkEntry,
86
+ type EditorialGenreMode,
87
+ type EditorialProcess,
88
+ type EditorialScope,
89
+ type EditorialStrictness,
90
+ type RunAutoReviewOptions,
91
+ type RunAutoReviewResult,
92
+ type ValidatedAutoReviewFinding,
93
+ } from './extensions/autoReviewPrompts.js'
94
+ export {
95
+ applyCommentReplacementById,
96
+ COMMENT_TYPES,
97
+ COMMENT_REVIEW_STATUSES,
98
+ COMMENT_SEVERITIES,
99
+ COMMENT_STATUS_LABELS,
100
+ COMMENT_TYPE_DEFINITIONS,
101
+ COMMENT_TYPE_LABELS,
102
+ defaultCommentReviewStatusForType,
103
+ defaultCommentSeverityForType,
104
+ normalizeCommentReviewStatus,
105
+ normalizeCommentSeverity,
106
+ normalizeCommentType,
107
+ type CommentMarkAttrPatch,
108
+ parseCommentReplies,
109
+ removeCommentMarksById,
110
+ updateCommentMarksById,
111
+ serializeCommentReplies,
112
+ type CommentReply,
113
+ type CommentReviewStatus,
114
+ type CommentSeverity,
115
+ type CommentType,
116
+ } from './commentUtils.js'
117
+ export { DocumentAssetIdentity } from './extensions/documentAssetIdentity.js'
118
+ export { Figure } from './extensions/figure.js'
119
+ export { Footnote } from './extensions/footnote.js'
120
+ export { IndexMarker } from './extensions/indexMarker.js'
121
+ export { MermaidBlock } from './extensions/mermaidBlock.js'
122
+ export {
123
+ expandMermaidBlocksForPrint,
124
+ extractMermaidSourceFromHtml,
125
+ renderMermaidPreviewSvg,
126
+ type ExpandMermaidBlocksOptions,
127
+ type MermaidPreviewTheme,
128
+ type RenderMermaidPreviewSvgOptions,
129
+ } from './mermaidPreview.js'
130
+ export { SmartTypography } from './extensions/smartTypography.js'
131
+ export {
132
+ SlashCommands,
133
+ createDefaultSlashCommands,
134
+ type SlashCommandItem,
135
+ type SlashCommandRange,
136
+ type SlashCommandRunOptions,
137
+ } from './extensions/slashCommands.js'
@@ -0,0 +1,60 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { insertCollectionImage } from './insertCollectionImage.js'
4
+
5
+ function createMockEditor(): {
6
+ editor: Editor
7
+ insertContent: ReturnType<typeof vi.fn>
8
+ run: ReturnType<typeof vi.fn>
9
+ } {
10
+ const run = vi.fn()
11
+ const insertContent = vi.fn().mockReturnThis()
12
+ const chain = {
13
+ focus: vi.fn().mockReturnThis(),
14
+ insertContent,
15
+ run,
16
+ }
17
+
18
+ const editor = {
19
+ isDestroyed: false,
20
+ chain: vi.fn(() => chain),
21
+ schema: { nodes: { image: {} } },
22
+ } as unknown as Editor
23
+
24
+ return { editor, insertContent, run }
25
+ }
26
+
27
+ describe('insertCollectionImage', () => {
28
+ it('inserts image node with data URL and collectionAssetSrc', () => {
29
+ const { editor, insertContent, run } = createMockEditor()
30
+ const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47])
31
+
32
+ insertCollectionImage(editor, {
33
+ relativePath: 'assets/figures/chart.png',
34
+ alt: 'Chart',
35
+ bytes,
36
+ mimeType: 'image/png',
37
+ })
38
+
39
+ expect(insertContent).toHaveBeenCalledWith({
40
+ type: 'image',
41
+ attrs: {
42
+ src: expect.stringMatching(/^data:image\/png;base64,/),
43
+ alt: 'Chart',
44
+ collectionAssetSrc: 'assets/figures/chart.png',
45
+ },
46
+ })
47
+ expect(run).toHaveBeenCalled()
48
+ })
49
+
50
+ it('returns false when editor is missing', () => {
51
+ expect(
52
+ insertCollectionImage(null, {
53
+ relativePath: 'assets/a.png',
54
+ alt: 'A',
55
+ bytes: new Uint8Array([1]),
56
+ mimeType: 'image/png',
57
+ }),
58
+ ).toBe(false)
59
+ })
60
+ })
@@ -0,0 +1,63 @@
1
+ import type { Editor } from '@tiptap/core'
2
+
3
+ export function uint8ArrayToBase64(bytes: Uint8Array): string {
4
+ let binary = ''
5
+ const chunk = 0x8000
6
+ for (let i = 0; i < bytes.length; i += chunk) {
7
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
8
+ }
9
+ return btoa(binary)
10
+ }
11
+
12
+ export interface InsertCollectionImageOptions {
13
+ relativePath: string
14
+ alt: string
15
+ bytes: Uint8Array
16
+ mimeType: string
17
+ }
18
+
19
+ /** Insert a file collection asset — data URL display + relative path for storage normalize. */
20
+ export function insertCollectionImage(
21
+ editor: Editor | null | undefined,
22
+ options: InsertCollectionImageOptions,
23
+ ): boolean {
24
+ if (!editor || editor.isDestroyed) return false
25
+ const imageNode = editor.schema.nodes.image
26
+ if (!imageNode) return false
27
+
28
+ const dataUrl = `data:${options.mimeType};base64,${uint8ArrayToBase64(
29
+ options.bytes,
30
+ )}`
31
+ editor
32
+ .chain()
33
+ .focus()
34
+ .insertContent({
35
+ type: 'image',
36
+ attrs: {
37
+ src: dataUrl,
38
+ alt: options.alt,
39
+ collectionAssetSrc: options.relativePath,
40
+ },
41
+ })
42
+ .run()
43
+ return true
44
+ }
45
+
46
+ export function insertCollectionImageFromBinaryResult(
47
+ editor: Editor | null | undefined,
48
+ options: {
49
+ relativePath: string
50
+ alt: string
51
+ mimeType: string
52
+ base64: string
53
+ },
54
+ ): boolean {
55
+ const binary = atob(options.base64)
56
+ const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
57
+ return insertCollectionImage(editor, {
58
+ relativePath: options.relativePath,
59
+ alt: options.alt,
60
+ bytes,
61
+ mimeType: options.mimeType,
62
+ })
63
+ }
@@ -0,0 +1,67 @@
1
+ import type { Editor } from '@tiptap/core'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { insertInlineAssetKind } from './insertInlineAssetKind.js'
4
+
5
+ function createMockEditor(
6
+ overrides: {
7
+ insertMermaidBlock?: ReturnType<typeof vi.fn>
8
+ insertContent?: ReturnType<typeof vi.fn>
9
+ insertBlockMath?: ReturnType<typeof vi.fn>
10
+ } = {},
11
+ ): Editor {
12
+ const run = vi.fn()
13
+ const chain = {
14
+ focus: vi.fn().mockReturnThis(),
15
+ insertMermaidBlock:
16
+ overrides.insertMermaidBlock ?? vi.fn().mockReturnThis(),
17
+ insertContent: overrides.insertContent ?? vi.fn().mockReturnThis(),
18
+ insertBlockMath: overrides.insertBlockMath ?? vi.fn().mockReturnThis(),
19
+ run,
20
+ }
21
+
22
+ return {
23
+ isDestroyed: false,
24
+ chain: vi.fn(() => chain),
25
+ } as unknown as Editor
26
+ }
27
+
28
+ describe('insertInlineAssetKind', () => {
29
+ it('inserts mermaid from sourceCode', () => {
30
+ const insertMermaidBlock = vi.fn().mockReturnThis()
31
+ const editor = createMockEditor({ insertMermaidBlock })
32
+
33
+ insertInlineAssetKind(editor, 'mermaid', 'flowchart LR\n A --> B')
34
+
35
+ expect(insertMermaidBlock).toHaveBeenCalledWith('flowchart LR\n A --> B')
36
+ })
37
+
38
+ it('inserts table HTML from sourceCode', () => {
39
+ const insertContent = vi.fn().mockReturnThis()
40
+ const editor = createMockEditor({ insertContent })
41
+ const table = '<table><tbody><tr><td>Cell</td></tr></tbody></table>'
42
+
43
+ insertInlineAssetKind(editor, 'table', table)
44
+
45
+ expect(insertContent).toHaveBeenCalledWith(table)
46
+ })
47
+
48
+ it('inserts block math via insertBlockMath when available', () => {
49
+ const insertBlockMath = vi.fn().mockReturnThis()
50
+ const editor = createMockEditor({ insertBlockMath })
51
+
52
+ insertInlineAssetKind(editor, 'math', '\\alpha + \\beta')
53
+
54
+ expect(insertBlockMath).toHaveBeenCalledWith({
55
+ latex: '\\alpha + \\beta',
56
+ })
57
+ })
58
+
59
+ it('no-ops when editor is destroyed', () => {
60
+ const editor = createMockEditor()
61
+ Object.defineProperty(editor, 'isDestroyed', { value: true })
62
+
63
+ insertInlineAssetKind(editor, 'mermaid', 'flowchart TD\n A --> B')
64
+
65
+ expect(editor.chain).not.toHaveBeenCalled()
66
+ })
67
+ })
@@ -0,0 +1,49 @@
1
+ import type { Editor } from '@tiptap/core'
2
+
3
+ export type InlineAssetInsertKind = 'mermaid' | 'table' | 'math'
4
+
5
+ const DEFAULT_MERMAID = `flowchart TD
6
+ A[Start] --> B[End]`
7
+
8
+ const DEFAULT_TABLE = '<table><tbody><tr><td></td></tr></tbody></table>'
9
+
10
+ const DEFAULT_MATH = 'E = mc^2'
11
+
12
+ /** TipTap commands for inline collection assets — no paths, no IO. */
13
+ export function insertInlineAssetKind(
14
+ editor: Editor | null | undefined,
15
+ kind: InlineAssetInsertKind,
16
+ sourceCode?: string,
17
+ ): void {
18
+ if (!editor || editor.isDestroyed) return
19
+
20
+ switch (kind) {
21
+ case 'mermaid':
22
+ editor
23
+ .chain()
24
+ .focus()
25
+ .insertMermaidBlock(sourceCode?.trim() || DEFAULT_MERMAID)
26
+ .run()
27
+ return
28
+ case 'table':
29
+ editor
30
+ .chain()
31
+ .focus()
32
+ .insertContent(sourceCode?.trim() || DEFAULT_TABLE)
33
+ .run()
34
+ return
35
+ case 'math': {
36
+ const latex = sourceCode?.trim() || DEFAULT_MATH
37
+ const commands = editor.chain().focus() as ReturnType<Editor['chain']> & {
38
+ insertBlockMath?: (opts: {
39
+ latex: string
40
+ }) => ReturnType<Editor['chain']>
41
+ }
42
+ if (typeof commands.insertBlockMath === 'function') {
43
+ commands.insertBlockMath({ latex }).run()
44
+ return
45
+ }
46
+ editor.chain().focus().insertContent(`$$${latex}$$`).run()
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,54 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const mermaidRender = vi.hoisted(() => vi.fn())
4
+
5
+ vi.mock('mermaid', () => ({
6
+ default: {
7
+ initialize: vi.fn(),
8
+ render: mermaidRender,
9
+ },
10
+ }))
11
+
12
+ import {
13
+ expandMermaidBlocksForPrint,
14
+ extractMermaidSourceFromHtml,
15
+ renderMermaidPreviewSvg,
16
+ } from './mermaidPreview.js'
17
+
18
+ describe('mermaidPreview', () => {
19
+ beforeEach(() => {
20
+ mermaidRender.mockReset()
21
+ mermaidRender.mockResolvedValue({
22
+ svg: '<svg data-testid="mermaid"></svg>',
23
+ })
24
+ })
25
+
26
+ it('extractMermaidSourceFromHtml preserves arrow syntax', () => {
27
+ const html =
28
+ '<figure data-type="mermaid"><pre><code class="language-mermaid">flowchart TD\n A --> B</code></pre></figure>'
29
+
30
+ expect(extractMermaidSourceFromHtml(html)).toBe('flowchart TD\n A --> B')
31
+ })
32
+
33
+ it('renderMermaidPreviewSvg returns svg from mermaid.render', async () => {
34
+ const svg = await renderMermaidPreviewSvg('flowchart TD\n A --> B', {
35
+ theme: 'default',
36
+ })
37
+ expect(mermaidRender).toHaveBeenCalled()
38
+ expect(svg).toContain('data-testid="mermaid"')
39
+ })
40
+
41
+ it('expandMermaidBlocksForPrint replaces blocks with SVG wrapper', async () => {
42
+ const html =
43
+ '<figure data-type="mermaid"><pre><code class="language-mermaid">flowchart TD\n A --> B</code></pre></figure>'
44
+
45
+ const expanded = await expandMermaidBlocksForPrint(html, {
46
+ theme: 'default',
47
+ })
48
+
49
+ expect(mermaidRender).toHaveBeenCalled()
50
+ expect(expanded).toContain('platform-print-mermaid')
51
+ expect(expanded).toContain('<svg data-testid="mermaid"></svg>')
52
+ expect(expanded).not.toContain('language-mermaid')
53
+ })
54
+ })