@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,63 @@
1
+ import type React from 'react'
2
+ import { styled } from 'styled-components'
3
+
4
+ export const ToolBtn = styled.button.attrs<{ $active?: boolean }>({
5
+ onMouseDown: (event: React.MouseEvent<HTMLButtonElement>) => {
6
+ event.preventDefault()
7
+ },
8
+ })<{ $active?: boolean }>`
9
+ align-items: center;
10
+ background: ${({ $active }) =>
11
+ $active
12
+ ? 'rgb(from var(--platform-colors-text) r g b / 0.05)'
13
+ : 'transparent'};
14
+ border: none;
15
+ border-radius: 6px;
16
+ color: var(--platform-colors-text);
17
+ cursor: pointer;
18
+ display: flex;
19
+ font-size: 13px;
20
+ font-weight: ${({ $active }) => ($active ? '600' : '400')};
21
+ height: 30px;
22
+ justify-content: center;
23
+ min-width: 30px;
24
+ padding: 0 7px;
25
+ transition: background 0.12s ease, color 0.12s ease, opacity 0.12s ease;
26
+
27
+ &:disabled {
28
+ cursor: not-allowed;
29
+ opacity: 0.35;
30
+ }
31
+
32
+ &:hover:not(:disabled) {
33
+ background: ${({ $active }) =>
34
+ $active
35
+ ? 'rgb(from var(--platform-colors-text) r g b / 0.08)'
36
+ : 'rgb(from var(--platform-colors-text) r g b / 0.045)'};
37
+ }
38
+ `
39
+
40
+ export const Divider = styled.span`
41
+ background: rgb(from var(--platform-colors-border) r g b / 0.92);
42
+ display: inline-block;
43
+ height: 16px;
44
+ margin: 0 12px;
45
+ width: 1px;
46
+ `
47
+
48
+ export const ToolbarRow = styled.div`
49
+ align-items: center;
50
+ display: flex;
51
+ flex-shrink: 0;
52
+ flex-wrap: wrap;
53
+ gap: 0;
54
+ margin: 0 auto;
55
+ padding: 4px 8px;
56
+ position: sticky;
57
+ top: 0;
58
+ z-index: 5;
59
+ width: 100%;
60
+ max-width: 680px;
61
+ background: var(--platform-colors-bg);
62
+ border-bottom: 1px solid var(--platform-colors-divider);
63
+ `
@@ -0,0 +1,325 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { EditorContext } from '@tiptap/react'
5
+ import { afterEach, describe, expect, it, vi } from 'vitest'
6
+ import { Toolbar } from './Toolbar.js'
7
+ ;(
8
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
9
+ ).IS_REACT_ACT_ENVIRONMENT = true
10
+
11
+ interface MockEditorOptions {
12
+ selection?: { from: number; to: number }
13
+ active?: Record<string, boolean>
14
+ autoReviewPrompts?: boolean
15
+ }
16
+
17
+ function createChain() {
18
+ const chain = {
19
+ focus: vi.fn(() => chain),
20
+ undo: vi.fn(() => chain),
21
+ redo: vi.fn(() => chain),
22
+ toggleBlockquote: vi.fn(() => chain),
23
+ toggleOrderedList: vi.fn(() => chain),
24
+ toggleBulletList: vi.fn(() => chain),
25
+ toggleBold: vi.fn(() => chain),
26
+ toggleItalic: vi.fn(() => chain),
27
+ toggleCode: vi.fn(() => chain),
28
+ toggleUnderline: vi.fn(() => chain),
29
+ setLink: vi.fn(() => chain),
30
+ unsetLink: vi.fn(() => chain),
31
+ sinkListItem: vi.fn(() => chain),
32
+ toggleSubscript: vi.fn(() => chain),
33
+ toggleSuperscript: vi.fn(() => chain),
34
+ insertInlineMath: vi.fn(() => chain),
35
+ insertBlockMath: vi.fn(() => chain),
36
+ insertMermaidBlock: vi.fn(() => chain),
37
+ toggleTaskList: vi.fn(() => chain),
38
+ toggleCodeBlock: vi.fn(() => chain),
39
+ insertTable: vi.fn(() => chain),
40
+ insertFootnote: vi.fn(() => chain),
41
+ toggleIndexMarker: vi.fn(() => chain),
42
+ addRowAfter: vi.fn(() => chain),
43
+ addColumnAfter: vi.fn(() => chain),
44
+ deleteTable: vi.fn(() => chain),
45
+ run: vi.fn(() => true),
46
+ }
47
+ return chain
48
+ }
49
+
50
+ function createEditor(options: MockEditorOptions = {}) {
51
+ const chain = createChain()
52
+ const handlers = new Map<string, Set<() => void>>()
53
+ const active = options.active ?? {}
54
+ const selection = options.selection ?? { from: 1, to: 1 }
55
+ const doc = {
56
+ descendants: vi.fn((visitor: (node: unknown, pos: number) => boolean) => {
57
+ visitor(
58
+ {
59
+ type: { name: 'paragraph' },
60
+ textContent: 'Small layout failures are easy to spot.',
61
+ nodeSize: 42,
62
+ },
63
+ 0,
64
+ )
65
+ }),
66
+ }
67
+ const editor = {
68
+ state: { selection, doc },
69
+ extensionManager: {
70
+ extensions: options.autoReviewPrompts
71
+ ? [{ name: 'autoReviewPrompts' }]
72
+ : [],
73
+ },
74
+ chain: vi.fn(() => chain),
75
+ can: vi.fn(() => ({
76
+ undo: vi.fn(() => true),
77
+ redo: vi.fn(() => false),
78
+ })),
79
+ isActive: vi.fn((name: string) => Boolean(active[name])),
80
+ getAttributes: vi.fn(() => ({ href: '' })),
81
+ on: vi.fn((event: string, handler: () => void) => {
82
+ if (!handlers.has(event)) handlers.set(event, new Set())
83
+ handlers.get(event)?.add(handler)
84
+ }),
85
+ off: vi.fn((event: string, handler: () => void) => {
86
+ handlers.get(event)?.delete(handler)
87
+ }),
88
+ emit(event: string) {
89
+ handlers.get(event)?.forEach(handler => handler())
90
+ },
91
+ }
92
+
93
+ return { editor, chain }
94
+ }
95
+
96
+ function renderToolbar({
97
+ editor,
98
+ onAddComment,
99
+ advancedTools,
100
+ }: {
101
+ editor: ReturnType<typeof createEditor>['editor']
102
+ onAddComment?: Parameters<typeof Toolbar>[0]['onAddComment']
103
+ advancedTools?: Parameters<typeof Toolbar>[0]['advancedTools']
104
+ }): { container: HTMLDivElement; root: Root } {
105
+ const container = document.createElement('div')
106
+ document.body.appendChild(container)
107
+ const root = createRoot(container)
108
+ act(() => {
109
+ root.render(
110
+ <EditorContext.Provider value={{ editor: editor as never }}>
111
+ <Toolbar onAddComment={onAddComment} advancedTools={advancedTools} />
112
+ </EditorContext.Provider>,
113
+ )
114
+ })
115
+ return { container, root }
116
+ }
117
+
118
+ function click(element: Element | null): void {
119
+ expect(element).not.toBeNull()
120
+ act(() => {
121
+ element?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
122
+ })
123
+ }
124
+
125
+ afterEach(() => {
126
+ vi.restoreAllMocks()
127
+ document.body.innerHTML = ''
128
+ })
129
+
130
+ describe('Toolbar', () => {
131
+ it('toggles core writing controls through the editor command chain', () => {
132
+ const { editor, chain } = createEditor()
133
+ const { container } = renderToolbar({ editor })
134
+
135
+ click(container.querySelector('[title="Bold"]'))
136
+ click(container.querySelector('[title="Italic"]'))
137
+ click(container.querySelector('[title="Underline"]'))
138
+ click(container.querySelector('[title="Inline code"]'))
139
+ click(container.querySelector('[title="Blockquote"]'))
140
+ click(container.querySelector('[title="Ordered list"]'))
141
+ click(container.querySelector('[title="Bullet list"]'))
142
+
143
+ expect(chain.toggleBold).toHaveBeenCalledOnce()
144
+ expect(chain.toggleItalic).toHaveBeenCalledOnce()
145
+ expect(chain.toggleUnderline).toHaveBeenCalledOnce()
146
+ expect(chain.toggleCode).toHaveBeenCalledOnce()
147
+ expect(chain.toggleBlockquote).toHaveBeenCalledOnce()
148
+ expect(chain.toggleOrderedList).toHaveBeenCalledOnce()
149
+ expect(chain.toggleBulletList).toHaveBeenCalledOnce()
150
+ })
151
+
152
+ it('opens the advanced content dropdown and closes it on outside click', () => {
153
+ const { editor } = createEditor()
154
+ const { container } = renderToolbar({ editor })
155
+
156
+ click(container.querySelector('[title="More tools"]'))
157
+
158
+ expect(container.textContent).toContain('Insert inline equation')
159
+ expect(container.textContent).toContain('Insert table')
160
+ expect(container.querySelector('[data-open="true"]')).not.toBeNull()
161
+
162
+ act(() => {
163
+ document.body.dispatchEvent(
164
+ new MouseEvent('mousedown', { bubbles: true }),
165
+ )
166
+ })
167
+
168
+ expect(container.querySelector('[data-open="false"]')).not.toBeNull()
169
+ })
170
+
171
+ it('runs advanced dropdown content commands', () => {
172
+ const promptSpy = vi
173
+ .spyOn(window, 'prompt')
174
+ .mockReturnValueOnce('E = mc^2')
175
+ .mockReturnValueOnce('a / b')
176
+ const { editor, chain } = createEditor()
177
+ const { container } = renderToolbar({ editor })
178
+
179
+ click(container.querySelector('[title="More tools"]'))
180
+ click(
181
+ Array.from(container.querySelectorAll('button')).find(button =>
182
+ button.textContent?.includes('Insert inline equation'),
183
+ ) ?? null,
184
+ )
185
+ click(container.querySelector('[title="More tools"]'))
186
+ click(
187
+ Array.from(container.querySelectorAll('button')).find(button =>
188
+ button.textContent?.includes('Insert display equation'),
189
+ ) ?? null,
190
+ )
191
+ click(container.querySelector('[title="More tools"]'))
192
+ click(
193
+ Array.from(container.querySelectorAll('button')).find(button =>
194
+ button.textContent?.includes('Insert Mermaid diagram'),
195
+ ) ?? null,
196
+ )
197
+ click(container.querySelector('[title="More tools"]'))
198
+ click(
199
+ Array.from(container.querySelectorAll('button')).find(button =>
200
+ button.textContent?.includes('Insert table'),
201
+ ) ?? null,
202
+ )
203
+
204
+ expect(promptSpy).toHaveBeenCalledTimes(2)
205
+ expect(chain.insertInlineMath).toHaveBeenCalledWith({ latex: 'E = mc^2' })
206
+ expect(chain.insertBlockMath).toHaveBeenCalledWith({ latex: 'a / b' })
207
+ expect(chain.insertMermaidBlock).toHaveBeenCalledOnce()
208
+ expect(chain.insertTable).toHaveBeenCalledWith({
209
+ rows: 3,
210
+ cols: 3,
211
+ withHeaderRow: true,
212
+ })
213
+ })
214
+
215
+ it('can limit the advanced dropdown to formatting-only tools', () => {
216
+ const { editor } = createEditor()
217
+ const { container } = renderToolbar({
218
+ editor,
219
+ advancedTools: 'formatting',
220
+ })
221
+
222
+ click(container.querySelector('[title="More tools"]'))
223
+
224
+ expect(container.textContent).toContain('Subscript')
225
+ expect(container.textContent).toContain('Superscript')
226
+ expect(container.textContent).toContain('Mark for index')
227
+ expect(container.textContent).not.toContain('Insert inline equation')
228
+ expect(container.textContent).not.toContain('Insert table')
229
+ expect(container.textContent).not.toContain('Copy editorial review prompts')
230
+ })
231
+
232
+ it('enables comment actions only when text is selected', () => {
233
+ const onAddComment = vi.fn()
234
+ const withoutSelection = createEditor()
235
+ const { container: disabledContainer } = renderToolbar({
236
+ editor: withoutSelection.editor,
237
+ onAddComment,
238
+ })
239
+
240
+ expect(
241
+ disabledContainer
242
+ .querySelector('[title="Add comment to selection"]')
243
+ ?.hasAttribute('disabled'),
244
+ ).toBe(true)
245
+
246
+ document.body.innerHTML = ''
247
+ const withSelection = createEditor({ selection: { from: 1, to: 8 } })
248
+ const { container } = renderToolbar({
249
+ editor: withSelection.editor,
250
+ onAddComment,
251
+ })
252
+
253
+ expect(
254
+ container
255
+ .querySelector('[title="Add comment to selection"]')
256
+ ?.hasAttribute('disabled'),
257
+ ).toBe(false)
258
+ })
259
+
260
+ it('shows table-specific commands when the cursor is in a table', () => {
261
+ const { editor, chain } = createEditor({ active: { table: true } })
262
+ const { container } = renderToolbar({ editor })
263
+
264
+ click(container.querySelector('[title="More tools"]'))
265
+
266
+ click(
267
+ Array.from(container.querySelectorAll('button')).find(button =>
268
+ button.textContent?.includes('Add table row'),
269
+ ) ?? null,
270
+ )
271
+ click(container.querySelector('[title="More tools"]'))
272
+ click(
273
+ Array.from(container.querySelectorAll('button')).find(button =>
274
+ button.textContent?.includes('Add table column'),
275
+ ) ?? null,
276
+ )
277
+ click(container.querySelector('[title="More tools"]'))
278
+ click(
279
+ Array.from(container.querySelectorAll('button')).find(button =>
280
+ button.textContent?.includes('Delete table'),
281
+ ) ?? null,
282
+ )
283
+
284
+ expect(chain.addRowAfter).toHaveBeenCalledOnce()
285
+ expect(chain.addColumnAfter).toHaveBeenCalledOnce()
286
+ expect(chain.deleteTable).toHaveBeenCalledOnce()
287
+ })
288
+
289
+ it('copies editorial review prompts when the auto-review extension is present', async () => {
290
+ const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
291
+ Object.defineProperty(navigator, 'clipboard', {
292
+ configurable: true,
293
+ value: clipboard,
294
+ })
295
+ const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {})
296
+ const { editor } = createEditor({ autoReviewPrompts: true })
297
+ const { container } = renderToolbar({ editor })
298
+
299
+ click(container.querySelector('[title="More tools"]'))
300
+ await act(async () => {
301
+ Array.from(container.querySelectorAll('button'))
302
+ .find(button =>
303
+ button.textContent?.includes('Copy editorial review prompts'),
304
+ )
305
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
306
+ })
307
+
308
+ expect(clipboard.writeText).toHaveBeenCalledOnce()
309
+ const copied = clipboard.writeText.mock.calls[0]?.[0] as string
310
+ expect(copied).toContain('# Claim detection prompt')
311
+ expect(copied).toContain('one thing only: CLAIMS')
312
+ expect(copied).toContain('# Fact-check detection prompt')
313
+ expect(copied).toContain('one thing only: FACT-CHECK NEEDS')
314
+ expect(copied).toContain('# Source detection prompt')
315
+ expect(copied).toContain('one thing only: SOURCE checks')
316
+ expect(copied).toContain('# Thread detection prompt')
317
+ expect(copied).toContain('You are detecting THREADS')
318
+ expect(copied).toContain('# Logic detection prompt')
319
+ expect(copied).toContain('You are detecting LOGIC gaps')
320
+ expect(copied).toContain('Small layout failures are easy to spot.')
321
+ expect(alertSpy).toHaveBeenCalledWith(
322
+ 'Copied editorial review prompts for 1 paragraph.',
323
+ )
324
+ })
325
+ })