@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,153 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState,
5
+ type CSSProperties,
6
+ type ReactElement,
7
+ } from 'react'
8
+ import { useCurrentEditor, useEditorState } from '@tiptap/react'
9
+ import { styled } from 'styled-components'
10
+ import { DownOutlined } from '@ant-design/icons'
11
+ import { ToolBtn } from '../ToolBtn.js'
12
+ import { CollapsePanel } from '../../CollapsePanel.js'
13
+
14
+ interface HeadingOption {
15
+ label: string
16
+ level: 0 | 1 | 2 | 3
17
+ style?: CSSProperties
18
+ }
19
+
20
+ const OPTIONS: HeadingOption[] = [
21
+ { label: 'Normal text', level: 0, style: { fontSize: 13 } },
22
+ { label: 'Title', level: 1, style: { fontSize: 22, fontWeight: 500 } },
23
+ { label: 'Heading', level: 2, style: { fontSize: 18, fontWeight: 500 } },
24
+ { label: 'Subheading', level: 3, style: { fontSize: 15, fontWeight: 500 } },
25
+ ]
26
+
27
+ export function HeadingGroup(): ReactElement {
28
+ const { editor } = useCurrentEditor()
29
+ const isH1 = useEditorState({
30
+ editor,
31
+ selector: ctx => ctx.editor?.isActive('heading', { level: 1 }) ?? false,
32
+ })
33
+ const isH2 = useEditorState({
34
+ editor,
35
+ selector: ctx => ctx.editor?.isActive('heading', { level: 2 }) ?? false,
36
+ })
37
+ const isH3 = useEditorState({
38
+ editor,
39
+ selector: ctx => ctx.editor?.isActive('heading', { level: 3 }) ?? false,
40
+ })
41
+
42
+ const [open, setOpen] = useState(false)
43
+ const wrapperRef = useRef<HTMLDivElement>(null)
44
+
45
+ const activeLevel = (isH1 && 1) || (isH2 && 2) || (isH3 && 3) || 0
46
+ const current = OPTIONS.find(o => o.level === activeLevel) ?? OPTIONS[0]
47
+
48
+ useEffect(() => {
49
+ if (!open) return
50
+ const onDown = (e: MouseEvent) => {
51
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false)
52
+ }
53
+ document.addEventListener('mousedown', onDown)
54
+ return () => document.removeEventListener('mousedown', onDown)
55
+ }, [open])
56
+
57
+ const apply = (level: 0 | 1 | 2 | 3) => {
58
+ setOpen(false)
59
+ if (!editor) return
60
+ if (level === 0) editor.chain().focus().setParagraph().run()
61
+ else editor.chain().focus().toggleHeading({ level }).run()
62
+ }
63
+
64
+ return (
65
+ <Wrapper ref={wrapperRef}>
66
+ <Trigger
67
+ $active={activeLevel !== 0}
68
+ onClick={() => setOpen(o => !o)}
69
+ title="Text style"
70
+ type="button"
71
+ >
72
+ <span>{current.label}</span>
73
+ <DownOutlined />
74
+ </Trigger>
75
+
76
+ <Panel open={open}>
77
+ {OPTIONS.map(opt => (
78
+ <Option
79
+ key={opt.level}
80
+ $active={opt.level === activeLevel}
81
+ style={opt.style}
82
+ onClick={() => apply(opt.level)}
83
+ type="button"
84
+ >
85
+ {opt.label}
86
+ </Option>
87
+ ))}
88
+ </Panel>
89
+ </Wrapper>
90
+ )
91
+ }
92
+
93
+ const Wrapper = styled.div`
94
+ position: relative;
95
+ `
96
+
97
+ const Trigger = styled(ToolBtn)`
98
+ gap: 6px;
99
+ min-width: 128px;
100
+ padding: 0 8px 0 10px;
101
+
102
+ > span:first-child {
103
+ flex: 1;
104
+ text-align: left;
105
+ }
106
+
107
+ .anticon {
108
+ font-size: 10px;
109
+ opacity: 0.6;
110
+ }
111
+ `
112
+
113
+ const Panel = styled(CollapsePanel)`
114
+ background: var(--platform-colors-surface);
115
+ border: 1px solid var(--platform-colors-border);
116
+ border-radius: 6px;
117
+ box-shadow: 0 6px 18px rgb(from var(--platform-colors-text) r g b / 12%);
118
+ left: 0;
119
+ min-width: 180px;
120
+ padding: 0;
121
+ position: absolute;
122
+ top: calc(100% + 4px);
123
+ z-index: 20;
124
+
125
+ &[data-open='false'] {
126
+ border: none;
127
+ box-shadow: none;
128
+ pointer-events: none;
129
+ }
130
+ `
131
+
132
+ const Option = styled.button<{ $active?: boolean }>`
133
+ align-items: center;
134
+ background: ${({ $active }) =>
135
+ $active
136
+ ? 'rgb(from var(--platform-colors-accent) r g b / 12%)'
137
+ : 'transparent'};
138
+ border: none;
139
+ border-radius: 4px;
140
+ color: ${({ $active }) =>
141
+ $active ? 'var(--platform-colors-accent)' : 'var(--platform-colors-text)'};
142
+ cursor: pointer;
143
+ display: flex;
144
+ font-family: var(--platform-typography-font-family);
145
+ padding: 6px 10px;
146
+ text-align: left;
147
+ transition: background 0.12s ease;
148
+ width: 100%;
149
+
150
+ &:hover {
151
+ background: rgb(from var(--platform-colors-text) r g b / 8%);
152
+ }
153
+ `
@@ -0,0 +1,28 @@
1
+ import type { ComponentType } from 'react'
2
+ import type { CommentType } from '../commentUtils.js'
3
+
4
+ export interface EditorLinkPromptProps {
5
+ open: boolean
6
+ value: string
7
+ onChange: (value: string) => void
8
+ onConfirm: () => void
9
+ onCancel: () => void
10
+ }
11
+
12
+ export interface EditorCommentPromptProps {
13
+ open: boolean
14
+ value: string
15
+ commentType: CommentType
16
+ commentTypeOptions: Array<{ value: CommentType; label: string }>
17
+ onChange: (value: string) => void
18
+ onCommentTypeChange: (value: CommentType) => void
19
+ onConfirm: () => void
20
+ onCancel: () => void
21
+ confirmDisabled?: boolean
22
+ }
23
+
24
+ /** Host-provided dialogs for toolbar prompts (use platform-ui overlays in apps). */
25
+ export interface EditorToolbarOverlays {
26
+ LinkPrompt: ComponentType<EditorLinkPromptProps>
27
+ CommentPrompt: ComponentType<EditorCommentPromptProps>
28
+ }
@@ -0,0 +1,22 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ buildExtensions,
4
+ type BuildExtensionsOptions,
5
+ type EditorFeatures,
6
+ } from './editorExtensions.js'
7
+ import type { Extensions } from '@tiptap/react'
8
+
9
+ export function useEditorExtensions(
10
+ opts: {
11
+ placeholder?: string
12
+ features?: EditorFeatures
13
+ } = {},
14
+ ): Extensions {
15
+ const { placeholder, features } = opts
16
+ return useMemo(
17
+ () => buildExtensions({ placeholder, features }),
18
+ [placeholder, features],
19
+ )
20
+ }
21
+
22
+ export type { BuildExtensionsOptions, EditorFeatures }
@@ -0,0 +1,331 @@
1
+ import { marked } from 'marked'
2
+ import TurndownService from 'turndown'
3
+
4
+ const turndownService = new TurndownService({
5
+ headingStyle: 'atx',
6
+ codeBlockStyle: 'fenced',
7
+ emDelimiter: '*',
8
+ strongDelimiter: '**',
9
+ bulletListMarker: '-',
10
+ hr: '---',
11
+ })
12
+
13
+ // Clean up whitespace in paragraphs and divs
14
+ turndownService.addRule('cleanWhitespace', {
15
+ filter: (node): boolean => node.nodeName === 'P' || node.nodeName === 'DIV',
16
+ replacement: (content: string): string => `${content.trim()}\n\n`,
17
+ })
18
+
19
+ turndownService.addRule('taskCheckbox', {
20
+ filter: (node): boolean =>
21
+ node.nodeName === 'INPUT' && (node as HTMLInputElement).type === 'checkbox',
22
+ replacement: (_content: string, node): string =>
23
+ (node as HTMLInputElement).checked ? '[x] ' : '[ ] ',
24
+ })
25
+
26
+ turndownService.addRule('taskItem', {
27
+ filter: (node): boolean =>
28
+ node.nodeName === 'LI' &&
29
+ (node as HTMLElement).dataset?.type === 'taskItem',
30
+ replacement: (content: string, node): string => {
31
+ const element = node as HTMLElement
32
+ const checked =
33
+ element.getAttribute('data-checked') === 'true' ||
34
+ (
35
+ element.querySelector(
36
+ 'input[type="checkbox"]',
37
+ ) as HTMLInputElement | null
38
+ )?.checked === true
39
+ const prefix = checked ? '- [x] ' : '- [ ] '
40
+ const body = content.replace(/^\s*\[(x| )\]\s*/i, '').trim()
41
+ if (!body) return `${prefix}\n`
42
+
43
+ if (looksLikeMarkdownTable(body)) {
44
+ return `${prefix}\n${indentMarkdown(body)}\n`
45
+ }
46
+
47
+ const [firstBlock, ...restBlocks] = body.split(/\n{2,}/)
48
+ const head = `${prefix}${firstBlock.trim()}`
49
+ if (restBlocks.length === 0) return `${head}\n`
50
+ return `${head}\n\n${indentMarkdown(restBlocks.join('\n\n').trim())}\n`
51
+ },
52
+ })
53
+
54
+ turndownService.addRule('table', {
55
+ filter: (node): boolean => node.nodeName === 'TABLE',
56
+ replacement: (_content: string, node): string => {
57
+ const table = node as HTMLTableElement
58
+ const rows = Array.from(table.querySelectorAll('tr')).map(row =>
59
+ Array.from(row.children).map(cell =>
60
+ normalizeCell(cell.textContent ?? ''),
61
+ ),
62
+ )
63
+ if (rows.length === 0) return ''
64
+
65
+ const header = rows[0]
66
+ const body = rows.slice(1)
67
+ const separator = header.map(() => '---')
68
+ const lines = [
69
+ `| ${header.join(' | ')} |`,
70
+ `| ${separator.join(' | ')} |`,
71
+ ...body.map(row => `| ${padRow(row, header.length).join(' | ')} |`),
72
+ ]
73
+
74
+ return `\n\n${lines.join('\n')}\n\n`
75
+ },
76
+ })
77
+
78
+ // Index markers (#172): <a class="ix" data-term="X">term</a> serializes
79
+ // back as raw HTML so the round-trip is lossless. CommonMark passes
80
+ // inline HTML through unchanged.
81
+ turndownService.keep(
82
+ node =>
83
+ node.nodeName === 'A' &&
84
+ (node as HTMLElement).classList?.contains('ix') === true,
85
+ )
86
+
87
+ turndownService.keep(
88
+ node =>
89
+ node.nodeName === 'SPAN' &&
90
+ (node as HTMLElement).hasAttribute?.('data-comment-id') === true,
91
+ )
92
+
93
+ turndownService.keep(node => node.nodeName === 'SUB' || node.nodeName === 'SUP')
94
+
95
+ turndownService.addRule('inlineMath', {
96
+ filter: (node): boolean =>
97
+ node.nodeName === 'SPAN' &&
98
+ (node as HTMLElement).dataset?.type === 'inline-math',
99
+ replacement: (_content: string, node): string => {
100
+ const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
101
+ return latex ? `$${latex}$` : ''
102
+ },
103
+ })
104
+
105
+ turndownService.addRule('blockMath', {
106
+ filter: (node): boolean =>
107
+ node.nodeName === 'DIV' &&
108
+ (node as HTMLElement).dataset?.type === 'block-math',
109
+ replacement: (_content: string, node): string => {
110
+ const latex = (node as HTMLElement).getAttribute('data-latex') ?? ''
111
+ return latex ? `\n\n$$\n${latex}\n$$\n\n` : ''
112
+ },
113
+ })
114
+
115
+ // <figure><img/><figcaption>caption</figcaption></figure> → ![caption](src)
116
+ // The render pipeline already wraps caption-bearing markdown images in
117
+ // a real <figure>, so this lossy serialization round-trips cleanly for
118
+ // PDF output. The alt attribute is used as the markdown alt text only
119
+ // when the figure has no caption.
120
+ turndownService.addRule('figure', {
121
+ filter: (node): boolean => node.nodeName === 'FIGURE',
122
+ replacement: (_content: string, node): string => {
123
+ const el = node as HTMLElement
124
+ const img = el.querySelector('img')
125
+ const captionEl = el.querySelector('figcaption')
126
+ if (!img) return ''
127
+ const src = img.getAttribute('src') ?? ''
128
+ const alt = img.getAttribute('alt') ?? ''
129
+ const caption = (captionEl?.textContent ?? '').trim()
130
+ const text = caption || alt
131
+ return `\n\n![${text}](${src})\n\n`
132
+ },
133
+ })
134
+
135
+ /** Convert markdown string to HTML. Returns empty string for falsy input. */
136
+ export const toHtml = (text: string): string =>
137
+ text ? renderMarkdownWithTaskLists(text) : ''
138
+
139
+ /** Convert HTML string to markdown. Returns empty string for falsy input. */
140
+ export const toMd = (html: string): string =>
141
+ html ? normalizeMarkdown(turndownService.turndown(html)) : ''
142
+
143
+ /** Batch-convert [key, markdown] entries to [key, html]. */
144
+ export const toHtmlEntries = (
145
+ entries: Array<[string, string]>,
146
+ ): Array<[string, string]> =>
147
+ entries.map(([key, value]) => [key, toHtml(value)])
148
+
149
+ /** Batch-convert [key, html] entries to [key, markdown]. */
150
+ export const toMdEntries = (
151
+ entries: Array<[string, string]>,
152
+ keyTransform?: (key: string) => string,
153
+ ): Array<[string, string]> =>
154
+ entries.map(([key, value]) => [
155
+ keyTransform ? keyTransform(key) : key,
156
+ toMd(value),
157
+ ])
158
+
159
+ function normalizeCell(value: string): string {
160
+ return value.replace(/\|/g, '\\|').replace(/\s+/g, ' ').trim()
161
+ }
162
+
163
+ function padRow(row: string[], length: number): string[] {
164
+ if (row.length >= length) return row
165
+ return [...row, ...Array.from({ length: length - row.length }, () => '')]
166
+ }
167
+
168
+ function normalizeMarkdown(markdown: string): string {
169
+ return markdown
170
+ .replace(/^- {2,}/gm, '- ')
171
+ .replace(/^(\s*[-*])\s+\[(x| )\]\s+/gim, '$1 [$2] ')
172
+ .replace(/\n{3,}/g, '\n\n')
173
+ .trimEnd()
174
+ }
175
+
176
+ function renderMarkdownWithTaskLists(markdown: string): string {
177
+ const normalized = markdown.replace(/\r\n?/g, '\n')
178
+ const lines = normalized.split('\n')
179
+ const parts: string[] = []
180
+ let buffer: string[] = []
181
+ let index = 0
182
+
183
+ const flushBuffer = (): void => {
184
+ if (buffer.length === 0) return
185
+ const chunk = buffer.join('\n')
186
+ if (chunk.trim()) {
187
+ parts.push(marked.parse(chunk) as string)
188
+ }
189
+ buffer = []
190
+ }
191
+
192
+ while (index < lines.length) {
193
+ const taskMatch = matchTaskLine(lines[index])
194
+ if (!taskMatch || taskMatch.indent !== 0) {
195
+ buffer.push(lines[index])
196
+ index += 1
197
+ continue
198
+ }
199
+
200
+ flushBuffer()
201
+ const rendered = renderTaskListBlock(lines, index, taskMatch.indent)
202
+ parts.push(rendered.html)
203
+ index = rendered.nextIndex
204
+ }
205
+
206
+ flushBuffer()
207
+ return parts.join('')
208
+ }
209
+
210
+ function renderTaskListBlock(
211
+ lines: string[],
212
+ startIndex: number,
213
+ baseIndent: number,
214
+ ): { html: string; nextIndex: number } {
215
+ const items: string[] = []
216
+ let index = startIndex
217
+
218
+ while (index < lines.length) {
219
+ while (
220
+ index < lines.length &&
221
+ lines[index].trim() === '' &&
222
+ matchTaskLine(lines[index + 1] ?? '')?.indent === baseIndent
223
+ ) {
224
+ index += 1
225
+ }
226
+
227
+ const taskMatch = matchTaskLine(lines[index] ?? '')
228
+ if (!taskMatch || taskMatch.indent !== baseIndent) break
229
+
230
+ const nestedLines: string[] = []
231
+ index += 1
232
+
233
+ while (index < lines.length) {
234
+ const line = lines[index]
235
+ const nextTask = matchTaskLine(line)
236
+ if (nextTask && nextTask.indent === baseIndent) break
237
+
238
+ if (line.trim() === '') {
239
+ nestedLines.push('')
240
+ index += 1
241
+ continue
242
+ }
243
+
244
+ const indent = countIndent(line)
245
+ if (indent <= baseIndent) break
246
+
247
+ nestedLines.push(stripNestedIndent(line, baseIndent))
248
+ index += 1
249
+ }
250
+
251
+ const itemBody = buildTaskItemBody(taskMatch.text, nestedLines)
252
+ items.push(
253
+ `<li data-type="taskItem" data-checked="${
254
+ taskMatch.checked
255
+ }"><label><input type="checkbox"${
256
+ taskMatch.checked ? ' checked="checked"' : ''
257
+ }><span></span></label><div>${itemBody}</div></li>`,
258
+ )
259
+ }
260
+
261
+ return {
262
+ html: `<ul data-type="taskList">${items.join('')}</ul>`,
263
+ nextIndex: index,
264
+ }
265
+ }
266
+
267
+ function buildTaskItemBody(mainText: string, nestedLines: string[]): string {
268
+ const nestedMarkdown = nestedLines.join('\n').trim()
269
+ const tableCandidate = [mainText, nestedMarkdown]
270
+ .filter(Boolean)
271
+ .join('\n')
272
+ .trim()
273
+
274
+ if (looksLikeMarkdownTable(tableCandidate)) {
275
+ return (marked.parse(tableCandidate) as string).trim()
276
+ }
277
+
278
+ const body: string[] = []
279
+ if (mainText.trim()) {
280
+ body.push(`<p>${marked.parseInline(mainText.trim()) as string}</p>`)
281
+ }
282
+ if (nestedMarkdown) {
283
+ body.push(renderMarkdownWithTaskLists(nestedMarkdown).trim())
284
+ }
285
+ if (body.length === 0) {
286
+ body.push('<p></p>')
287
+ }
288
+ return body.join('')
289
+ }
290
+
291
+ function looksLikeMarkdownTable(markdown: string): boolean {
292
+ const lines = markdown
293
+ .split('\n')
294
+ .map(line => line.trim())
295
+ .filter(Boolean)
296
+ if (lines.length < 2) return false
297
+ return (
298
+ /^\|.*\|$/.test(lines[0]) &&
299
+ /^\|?(?:\s*:?-+:?\s*\|)+\s*:?-+:?\s*\|?$/.test(lines[1])
300
+ )
301
+ }
302
+
303
+ function matchTaskLine(
304
+ line: string,
305
+ ): { indent: number; checked: boolean; text: string } | null {
306
+ const match = line.match(/^(\s*)[-+*]\s+\[([ xX])\]\s*(.*)$/)
307
+ if (!match) return null
308
+ return {
309
+ indent: match[1].length,
310
+ checked: match[2].toLowerCase() === 'x',
311
+ text: match[3] ?? '',
312
+ }
313
+ }
314
+
315
+ function countIndent(line: string): number {
316
+ const match = line.match(/^(\s*)/)
317
+ return match?.[1].length ?? 0
318
+ }
319
+
320
+ function stripNestedIndent(line: string, baseIndent: number): string {
321
+ const preferred = ' '.repeat(baseIndent + 4)
322
+ if (line.startsWith(preferred)) return line.slice(preferred.length)
323
+ return line.slice(Math.min(line.length, baseIndent + 2))
324
+ }
325
+
326
+ function indentMarkdown(markdown: string): string {
327
+ return markdown
328
+ .split('\n')
329
+ .map(line => (line.trim() ? ` ${line}` : ''))
330
+ .join('\n')
331
+ }