@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,297 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core'
2
+ import {
3
+ NodeViewWrapper,
4
+ ReactNodeViewRenderer,
5
+ type NodeViewProps,
6
+ } from '@tiptap/react'
7
+ import { useEffect, useState, type ChangeEvent } from 'react'
8
+ import { styled } from 'styled-components'
9
+ import { renderMermaidPreviewSvg } from '../mermaidPreview.js'
10
+
11
+ export interface MermaidBlockOptions {
12
+ HTMLAttributes: Record<string, unknown>
13
+ }
14
+
15
+ declare module '@tiptap/core' {
16
+ interface Commands<ReturnType> {
17
+ mermaidBlock: {
18
+ /** Insert an editable Mermaid diagram block. */
19
+ insertMermaidBlock: (code?: string) => ReturnType
20
+ }
21
+ }
22
+ }
23
+
24
+ const DEFAULT_MERMAID = `flowchart TD
25
+ A[Start] --> B[Draft]
26
+ B --> C[Review]
27
+ C --> D[Publish]`
28
+
29
+ function readMermaidCode(element: HTMLElement): string {
30
+ const source = element.matches('code')
31
+ ? element
32
+ : element.querySelector('pre code.language-mermaid, code.language-mermaid')
33
+ return (source?.textContent ?? element.getAttribute('data-code') ?? '').trim()
34
+ }
35
+
36
+ function createMermaidAssetId(): string {
37
+ return `asset_${
38
+ globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)
39
+ }`
40
+ }
41
+
42
+ export const MermaidBlock = Node.create<MermaidBlockOptions>({
43
+ name: 'mermaidBlock',
44
+ group: 'block',
45
+ atom: true,
46
+ isolating: true,
47
+ defining: true,
48
+ draggable: true,
49
+
50
+ addOptions() {
51
+ return { HTMLAttributes: {} }
52
+ },
53
+
54
+ addAttributes() {
55
+ return {
56
+ code: {
57
+ default: DEFAULT_MERMAID,
58
+ parseHTML: element => readMermaidCode(element as HTMLElement),
59
+ rendered: false,
60
+ },
61
+ assetId: {
62
+ default: null,
63
+ parseHTML: element =>
64
+ (element as HTMLElement).getAttribute('data-asset-id')?.trim() ||
65
+ null,
66
+ },
67
+ }
68
+ },
69
+
70
+ parseHTML() {
71
+ return [
72
+ { tag: 'figure[data-type="mermaid"]' },
73
+ { tag: 'div[data-type="mermaid"]' },
74
+ { tag: 'pre code.language-mermaid' },
75
+ ]
76
+ },
77
+
78
+ renderHTML({ node, HTMLAttributes }) {
79
+ const code = String(node.attrs.code ?? DEFAULT_MERMAID)
80
+ const assetId = String(node.attrs.assetId ?? '').trim()
81
+ return [
82
+ 'figure',
83
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
84
+ 'data-type': 'mermaid',
85
+ ...(assetId ? { 'data-asset-id': assetId } : {}),
86
+ class: 'mermaid-block',
87
+ }),
88
+ ['pre', {}, ['code', { class: 'language-mermaid' }, code]],
89
+ ]
90
+ },
91
+
92
+ addNodeView() {
93
+ return ReactNodeViewRenderer(MermaidBlockView)
94
+ },
95
+
96
+ addCommands() {
97
+ return {
98
+ insertMermaidBlock:
99
+ (code = DEFAULT_MERMAID) =>
100
+ ({ commands }) =>
101
+ commands.insertContent({
102
+ type: this.name,
103
+ attrs: { code, assetId: createMermaidAssetId() },
104
+ }),
105
+ }
106
+ },
107
+ })
108
+
109
+ function MermaidBlockView({
110
+ node,
111
+ selected,
112
+ updateAttributes,
113
+ }: NodeViewProps): React.ReactElement {
114
+ const code = String(node.attrs.code ?? DEFAULT_MERMAID)
115
+ const assetId = String(node.attrs.assetId ?? '').trim()
116
+ const [isEditing, setIsEditing] = useState(false)
117
+ const [svg, setSvg] = useState('')
118
+ const [error, setError] = useState<string | null>(null)
119
+ const [isRendering, setIsRendering] = useState(true)
120
+
121
+ useEffect(() => {
122
+ if (!assetId) updateAttributes({ assetId: createMermaidAssetId() })
123
+ }, [assetId, updateAttributes])
124
+
125
+ useEffect(() => {
126
+ let cancelled = false
127
+ setIsRendering(true)
128
+ const timeout = window.setTimeout(
129
+ () =>
130
+ void (async () => {
131
+ try {
132
+ const svg = await renderMermaidPreviewSvg(
133
+ code.trim() || DEFAULT_MERMAID,
134
+ )
135
+ if (cancelled) return
136
+ setSvg(svg)
137
+ setError(null)
138
+ } catch (err) {
139
+ if (cancelled) return
140
+ setSvg('')
141
+ setError(
142
+ err instanceof Error ? err.message : 'Could not render diagram.',
143
+ )
144
+ } finally {
145
+ if (!cancelled) setIsRendering(false)
146
+ }
147
+ })(),
148
+ 120,
149
+ )
150
+ return () => {
151
+ cancelled = true
152
+ window.clearTimeout(timeout)
153
+ }
154
+ }, [code])
155
+
156
+ return (
157
+ <NodeViewWrapper>
158
+ <StyledBlock $selected={selected}>
159
+ <StyledHeader contentEditable={false}>
160
+ <StyledEyebrow>Mermaid diagram</StyledEyebrow>
161
+ <StyledToggle
162
+ type="button"
163
+ onClick={() => setIsEditing(current => !current)}
164
+ >
165
+ {isEditing ? 'Preview' : 'Edit'}
166
+ </StyledToggle>
167
+ </StyledHeader>
168
+ {isEditing ? (
169
+ <StyledEditorPanel contentEditable={false}>
170
+ <StyledTextarea
171
+ value={code}
172
+ spellCheck={false}
173
+ onChange={(event: ChangeEvent<HTMLTextAreaElement>) =>
174
+ updateAttributes({ code: event.target.value })
175
+ }
176
+ />
177
+ </StyledEditorPanel>
178
+ ) : null}
179
+ <StyledPreview contentEditable={false}>
180
+ {isRendering ? (
181
+ <StyledLoading>Rendering diagram…</StyledLoading>
182
+ ) : error ? (
183
+ <StyledError>
184
+ <strong>Mermaid could not render this diagram.</strong>
185
+ <span>{error}</span>
186
+ </StyledError>
187
+ ) : (
188
+ <StyledSvg dangerouslySetInnerHTML={{ __html: svg }} />
189
+ )}
190
+ </StyledPreview>
191
+ </StyledBlock>
192
+ </NodeViewWrapper>
193
+ )
194
+ }
195
+
196
+ const StyledBlock = styled.figure<{ $selected: boolean }>`
197
+ background: var(--platform-colors-surface);
198
+ border: 1px solid
199
+ ${({ $selected }) =>
200
+ $selected
201
+ ? 'var(--platform-colors-focus)'
202
+ : 'rgb(from var(--platform-colors-border) r g b / 0.9)'};
203
+ border-radius: 14px;
204
+ box-shadow: ${({ $selected }) =>
205
+ $selected
206
+ ? '0 0 0 3px rgb(from var(--platform-colors-focus) r g b / 0.16)'
207
+ : 'none'};
208
+ margin: 1.4em 0;
209
+ overflow: hidden;
210
+ `
211
+
212
+ const StyledHeader = styled.div`
213
+ align-items: center;
214
+ border-bottom: 1px solid rgb(from var(--platform-colors-border) r g b / 0.68);
215
+ display: flex;
216
+ justify-content: space-between;
217
+ padding: 10px 12px;
218
+ `
219
+
220
+ const StyledEyebrow = styled.span`
221
+ color: var(--platform-colors-text-secondary);
222
+ font-size: 11px;
223
+ font-weight: 700;
224
+ letter-spacing: 0.12em;
225
+ text-transform: uppercase;
226
+ `
227
+
228
+ const StyledToggle = styled.button`
229
+ background: transparent;
230
+ border: 1px solid rgb(from var(--platform-colors-border) r g b / 0.82);
231
+ border-radius: 999px;
232
+ color: var(--platform-colors-text);
233
+ font-size: 12px;
234
+ font-weight: 650;
235
+ padding: 5px 10px;
236
+
237
+ &:hover {
238
+ background: var(--platform-colors-surface-hover);
239
+ }
240
+ `
241
+
242
+ const StyledEditorPanel = styled.div`
243
+ border-bottom: 1px solid rgb(from var(--platform-colors-border) r g b / 0.68);
244
+ padding: 12px;
245
+ `
246
+
247
+ const StyledTextarea = styled.textarea`
248
+ background: rgb(from var(--platform-colors-app-viewport) r g b / 0.74);
249
+ border: 1px solid rgb(from var(--platform-colors-border) r g b / 0.82);
250
+ border-radius: 10px;
251
+ color: var(--platform-colors-text);
252
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
253
+ font-size: 13px;
254
+ line-height: 1.45;
255
+ min-height: 160px;
256
+ padding: 12px;
257
+ resize: vertical;
258
+ width: 100%;
259
+ `
260
+
261
+ const StyledPreview = styled.div`
262
+ align-items: center;
263
+ background: rgb(from var(--platform-colors-app-viewport) r g b / 0.42);
264
+ display: flex;
265
+ justify-content: center;
266
+ min-height: 180px;
267
+ padding: 18px;
268
+ `
269
+
270
+ const StyledSvg = styled.div`
271
+ max-width: 100%;
272
+ overflow: auto;
273
+
274
+ svg {
275
+ display: block;
276
+ height: auto;
277
+ max-width: 100%;
278
+ }
279
+ `
280
+
281
+ const StyledLoading = styled.div`
282
+ color: var(--platform-colors-text-secondary);
283
+ font-size: 13px;
284
+ `
285
+
286
+ const StyledError = styled.div`
287
+ color: var(--platform-colors-text-danger);
288
+ display: grid;
289
+ gap: 6px;
290
+ justify-items: center;
291
+ text-align: center;
292
+
293
+ span {
294
+ color: var(--platform-colors-text-secondary);
295
+ font-size: 12px;
296
+ }
297
+ `
@@ -0,0 +1,170 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createDefaultSlashCommands,
4
+ type SlashCommandItem,
5
+ } from './slashCommands.js'
6
+
7
+ function createEditor() {
8
+ const chain = {
9
+ focus: vi.fn(() => chain),
10
+ deleteRange: vi.fn(() => chain),
11
+ setTextSelection: vi.fn(() => chain),
12
+ setParagraph: vi.fn(() => chain),
13
+ setNode: vi.fn(() => chain),
14
+ toggleBold: vi.fn(() => chain),
15
+ toggleItalic: vi.fn(() => chain),
16
+ toggleUnderline: vi.fn(() => chain),
17
+ toggleCode: vi.fn(() => chain),
18
+ toggleBulletList: vi.fn(() => chain),
19
+ toggleOrderedList: vi.fn(() => chain),
20
+ toggleTaskList: vi.fn(() => chain),
21
+ sinkListItem: vi.fn(() => chain),
22
+ liftListItem: vi.fn(() => chain),
23
+ toggleBlockquote: vi.fn(() => chain),
24
+ toggleCodeBlock: vi.fn(() => chain),
25
+ insertTable: vi.fn(() => chain),
26
+ insertFootnote: vi.fn(() => chain),
27
+ toggleIndexMarker: vi.fn(() => chain),
28
+ insertMermaidBlock: vi.fn(() => chain),
29
+ run: vi.fn(() => true),
30
+ }
31
+ const editor = {
32
+ chain: vi.fn(() => chain),
33
+ }
34
+ return { editor, chain }
35
+ }
36
+
37
+ function runCommand(item: SlashCommandItem) {
38
+ const { editor, chain } = createEditor()
39
+ item.run({
40
+ editor: editor as never,
41
+ range: { from: 2, to: 8 },
42
+ query: item.title,
43
+ source: 'typed',
44
+ })
45
+ return { editor, chain }
46
+ }
47
+
48
+ function runSelectionCommand(item: SlashCommandItem) {
49
+ const { editor, chain } = createEditor()
50
+ item.run({
51
+ editor: editor as never,
52
+ range: { from: 2, to: 8 },
53
+ query: item.title,
54
+ source: 'selection',
55
+ })
56
+ return { editor, chain }
57
+ }
58
+
59
+ describe('createDefaultSlashCommands', () => {
60
+ it('provides the default PureWriter content command inventory', () => {
61
+ expect(createDefaultSlashCommands().map(item => item.id)).toEqual([
62
+ 'paragraph',
63
+ 'heading-1',
64
+ 'heading-2',
65
+ 'heading-3',
66
+ 'bold',
67
+ 'italic',
68
+ 'underline',
69
+ 'inline-code',
70
+ 'link',
71
+ 'bullet-list',
72
+ 'numbered-list',
73
+ 'task-list',
74
+ 'indent-list',
75
+ 'outdent-list',
76
+ 'quote',
77
+ 'code-block',
78
+ 'table',
79
+ 'footnote',
80
+ 'index-marker',
81
+ 'inline-equation',
82
+ 'display-equation',
83
+ 'mermaid',
84
+ ])
85
+ })
86
+
87
+ it('turns slash text into a paragraph', () => {
88
+ const paragraph = createDefaultSlashCommands().find(
89
+ item => item.id === 'paragraph',
90
+ )
91
+ expect(paragraph).toBeDefined()
92
+
93
+ const { chain } = runCommand(paragraph!)
94
+
95
+ expect(chain.deleteRange).toHaveBeenCalledWith({ from: 2, to: 8 })
96
+ expect(chain.setParagraph).toHaveBeenCalledOnce()
97
+ expect(chain.run).toHaveBeenCalledOnce()
98
+ })
99
+
100
+ it('turns slash text into headings', () => {
101
+ const commands = createDefaultSlashCommands()
102
+
103
+ const h1 = runCommand(commands.find(item => item.id === 'heading-1')!)
104
+ const h2 = runCommand(commands.find(item => item.id === 'heading-2')!)
105
+ const h3 = runCommand(commands.find(item => item.id === 'heading-3')!)
106
+
107
+ expect(h1.chain.setNode).toHaveBeenCalledWith('heading', { level: 1 })
108
+ expect(h2.chain.setNode).toHaveBeenCalledWith('heading', { level: 2 })
109
+ expect(h3.chain.setNode).toHaveBeenCalledWith('heading', { level: 3 })
110
+ })
111
+
112
+ it('turns slash text into mark, list, and block commands', () => {
113
+ const commands = createDefaultSlashCommands()
114
+
115
+ const bold = runCommand(commands.find(item => item.id === 'bold')!)
116
+ const italic = runCommand(commands.find(item => item.id === 'italic')!)
117
+ const underline = runCommand(
118
+ commands.find(item => item.id === 'underline')!,
119
+ )
120
+ const inlineCode = runCommand(
121
+ commands.find(item => item.id === 'inline-code')!,
122
+ )
123
+ const bullet = runCommand(commands.find(item => item.id === 'bullet-list')!)
124
+ const numbered = runCommand(
125
+ commands.find(item => item.id === 'numbered-list')!,
126
+ )
127
+ const task = runCommand(commands.find(item => item.id === 'task-list')!)
128
+ const indent = runCommand(commands.find(item => item.id === 'indent-list')!)
129
+ const outdent = runCommand(
130
+ commands.find(item => item.id === 'outdent-list')!,
131
+ )
132
+ const quote = runCommand(commands.find(item => item.id === 'quote')!)
133
+ const code = runCommand(commands.find(item => item.id === 'code-block')!)
134
+ const table = runCommand(commands.find(item => item.id === 'table')!)
135
+ const footnote = runCommand(commands.find(item => item.id === 'footnote')!)
136
+ const index = runCommand(
137
+ commands.find(item => item.id === 'index-marker')!,
138
+ )
139
+ const mermaid = runCommand(commands.find(item => item.id === 'mermaid')!)
140
+
141
+ expect(bold.chain.toggleBold).toHaveBeenCalledOnce()
142
+ expect(italic.chain.toggleItalic).toHaveBeenCalledOnce()
143
+ expect(underline.chain.toggleUnderline).toHaveBeenCalledOnce()
144
+ expect(inlineCode.chain.toggleCode).toHaveBeenCalledOnce()
145
+ expect(bullet.chain.toggleBulletList).toHaveBeenCalledOnce()
146
+ expect(numbered.chain.toggleOrderedList).toHaveBeenCalledOnce()
147
+ expect(task.chain.toggleTaskList).toHaveBeenCalledOnce()
148
+ expect(indent.chain.sinkListItem).toHaveBeenCalledWith('listItem')
149
+ expect(outdent.chain.liftListItem).toHaveBeenCalledWith('listItem')
150
+ expect(quote.chain.toggleBlockquote).toHaveBeenCalledOnce()
151
+ expect(code.chain.toggleCodeBlock).toHaveBeenCalledOnce()
152
+ expect(table.chain.insertTable).toHaveBeenCalledWith({
153
+ rows: 3,
154
+ cols: 3,
155
+ withHeaderRow: true,
156
+ })
157
+ expect(footnote.chain.insertFootnote).toHaveBeenCalledOnce()
158
+ expect(index.chain.toggleIndexMarker).toHaveBeenCalledOnce()
159
+ expect(mermaid.chain.insertMermaidBlock).toHaveBeenCalledOnce()
160
+ })
161
+
162
+ it('does not delete selected text when a slash command is run on a selection', () => {
163
+ const commands = createDefaultSlashCommands()
164
+ const bold = runSelectionCommand(commands.find(item => item.id === 'bold')!)
165
+
166
+ expect(bold.chain.deleteRange).not.toHaveBeenCalled()
167
+ expect(bold.chain.setTextSelection).toHaveBeenCalledWith({ from: 2, to: 8 })
168
+ expect(bold.chain.toggleBold).toHaveBeenCalledOnce()
169
+ })
170
+ })