@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.
- package/package.json +63 -0
- package/src/CollapsePanel.tsx +35 -0
- package/src/DocumentDiffView.tsx +130 -0
- package/src/DocumentEditor.test.ts +118 -0
- package/src/DocumentEditor.tsx +2631 -0
- package/src/alignedLineDiff.test.ts +52 -0
- package/src/alignedLineDiff.ts +67 -0
- package/src/collectionAssetSrc.ts +2 -0
- package/src/commentUtils.test.ts +350 -0
- package/src/commentUtils.ts +546 -0
- package/src/constants/toolKeys.ts +14 -0
- package/src/contentFormat.test.ts +27 -0
- package/src/contentFormat.ts +18 -0
- package/src/editorExtensions.ts +155 -0
- package/src/extensions/appliedChangeMark.ts +115 -0
- package/src/extensions/autoReviewPrompts.test.ts +529 -0
- package/src/extensions/autoReviewPrompts.ts +1442 -0
- package/src/extensions/collectionImage.ts +26 -0
- package/src/extensions/collectionImagePaste.ts +215 -0
- package/src/extensions/commentMark.ts +223 -0
- package/src/extensions/documentAssetIdentity.ts +54 -0
- package/src/extensions/figure.ts +92 -0
- package/src/extensions/footnote.ts +186 -0
- package/src/extensions/indexMarker.ts +88 -0
- package/src/extensions/mathEditing.ts +239 -0
- package/src/extensions/mermaidBlock.tsx +297 -0
- package/src/extensions/slashCommands.test.ts +170 -0
- package/src/extensions/slashCommands.ts +746 -0
- package/src/extensions/smartTypography.test.ts +74 -0
- package/src/extensions/smartTypography.ts +120 -0
- package/src/index.ts +137 -0
- package/src/insertCollectionImage.test.ts +60 -0
- package/src/insertCollectionImage.ts +63 -0
- package/src/insertInlineAssetKind.test.ts +67 -0
- package/src/insertInlineAssetKind.ts +49 -0
- package/src/mermaidPreview.test.ts +54 -0
- package/src/mermaidPreview.ts +115 -0
- package/src/styled.ts +676 -0
- package/src/toolbar/ToolBtn.tsx +63 -0
- package/src/toolbar/Toolbar.test.tsx +325 -0
- package/src/toolbar/Toolbar.tsx +624 -0
- package/src/toolbar/groups/HeadingGroup.tsx +153 -0
- package/src/toolbar/overlayTypes.ts +28 -0
- package/src/useEditorExtensions.ts +22 -0
- 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
|
+
})
|