@puredesktop/platform-editor 1.0.0-beta.1 → 2.1.0

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 +1 -1
  2. package/src/CollapsePanel.tsx +35 -35
  3. package/src/DocumentDiffView.tsx +130 -130
  4. package/src/DocumentEditor.test.ts +118 -118
  5. package/src/DocumentEditor.tsx +2651 -2631
  6. package/src/alignedLineDiff.test.ts +52 -52
  7. package/src/alignedLineDiff.ts +67 -67
  8. package/src/collectionAssetSrc.ts +2 -2
  9. package/src/commentUtils.test.ts +350 -350
  10. package/src/commentUtils.ts +546 -546
  11. package/src/constants/toolKeys.ts +14 -14
  12. package/src/contentFormat.test.ts +27 -27
  13. package/src/contentFormat.ts +18 -18
  14. package/src/editorExtensions.ts +155 -155
  15. package/src/extensions/appliedChangeMark.ts +115 -115
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -529
  17. package/src/extensions/autoReviewPrompts.ts +1442 -1442
  18. package/src/extensions/collectionImage.ts +26 -26
  19. package/src/extensions/collectionImagePaste.ts +215 -215
  20. package/src/extensions/commentMark.ts +223 -223
  21. package/src/extensions/documentAssetIdentity.ts +54 -54
  22. package/src/extensions/figure.ts +92 -92
  23. package/src/extensions/footnote.ts +186 -186
  24. package/src/extensions/indexMarker.ts +88 -88
  25. package/src/extensions/mathEditing.ts +239 -239
  26. package/src/extensions/mermaidBlock.tsx +297 -297
  27. package/src/extensions/slashCommands.test.ts +170 -170
  28. package/src/extensions/slashCommands.ts +746 -746
  29. package/src/extensions/smartTypography.test.ts +74 -74
  30. package/src/extensions/smartTypography.ts +120 -120
  31. package/src/index.ts +138 -137
  32. package/src/insertCollectionImage.test.ts +60 -60
  33. package/src/insertCollectionImage.ts +63 -63
  34. package/src/insertInlineAssetKind.test.ts +67 -67
  35. package/src/insertInlineAssetKind.ts +49 -49
  36. package/src/mermaidPreview.test.ts +54 -54
  37. package/src/mermaidPreview.ts +115 -115
  38. package/src/styled.ts +697 -676
  39. package/src/toolbar/ToolBtn.tsx +77 -63
  40. package/src/toolbar/Toolbar.test.tsx +325 -325
  41. package/src/toolbar/Toolbar.tsx +662 -624
  42. package/src/toolbar/groups/HeadingGroup.tsx +175 -153
  43. package/src/toolbar/overlayTypes.ts +28 -28
  44. package/src/useEditorExtensions.ts +22 -22
  45. package/src/utils/markdownUtils.ts +331 -331
@@ -1,297 +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
- `
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
+ `