@novacraft-engineering/mailbox 0.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.
- package/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { EditorContent, NodeViewWrapper, ReactNodeViewRenderer, useEditor, type Editor, type NodeViewProps } from '@tiptap/react'
|
|
5
|
+
import StarterKit from '@tiptap/starter-kit'
|
|
6
|
+
import Underline from '@tiptap/extension-underline'
|
|
7
|
+
import Link from '@tiptap/extension-link'
|
|
8
|
+
import Image from '@tiptap/extension-image'
|
|
9
|
+
import { CLIENT_BRAND } from '@/lib/brand.client'
|
|
10
|
+
import TextAlign from '@tiptap/extension-text-align'
|
|
11
|
+
import { FontFamily, FontSize, TextStyle } from '@tiptap/extension-text-style'
|
|
12
|
+
import { BUILTIN_FONTS, FONT_SIZES, fontStack, type BaseFont } from '@/lib/fonts'
|
|
13
|
+
import MailSelect from './MailSelect'
|
|
14
|
+
import { Color } from '@tiptap/extension-color'
|
|
15
|
+
import Highlight from '@tiptap/extension-highlight'
|
|
16
|
+
import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
|
|
17
|
+
import { safeHref } from '@/lib/email-html'
|
|
18
|
+
import styles from './page.module.css'
|
|
19
|
+
|
|
20
|
+
const TEXT_COLOURS = ['#030712', '#b91c1c', '#1d4ed8', '#047857', '#b45309', '#6d28d9', '#6b7280']
|
|
21
|
+
const HIGHLIGHTS = ['#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#FED7AA']
|
|
22
|
+
function Group({ children }: { children: React.ReactNode }) {
|
|
23
|
+
return <div className={styles.rteGroup}>{children}</div>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Btn({
|
|
27
|
+
onClick,
|
|
28
|
+
active,
|
|
29
|
+
title,
|
|
30
|
+
children,
|
|
31
|
+
wide,
|
|
32
|
+
}: {
|
|
33
|
+
onClick: () => void
|
|
34
|
+
active?: boolean
|
|
35
|
+
title: string
|
|
36
|
+
children: React.ReactNode
|
|
37
|
+
wide?: boolean
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
title={title}
|
|
43
|
+
aria-label={title}
|
|
44
|
+
aria-pressed={active}
|
|
45
|
+
onMouseDown={event => event.preventDefault()} // keep the selection while clicking
|
|
46
|
+
onClick={onClick}
|
|
47
|
+
className={`${styles.rteBtn} ${wide ? styles.rteBtnWide : ''} ${active ? styles.rteBtnOn : ''}`}
|
|
48
|
+
>
|
|
49
|
+
{children}
|
|
50
|
+
</button>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Field({
|
|
55
|
+
label,
|
|
56
|
+
value,
|
|
57
|
+
onChange,
|
|
58
|
+
placeholder,
|
|
59
|
+
autoFocus,
|
|
60
|
+
onEnter,
|
|
61
|
+
}: {
|
|
62
|
+
label: string
|
|
63
|
+
value: string
|
|
64
|
+
onChange: (next: string) => void
|
|
65
|
+
placeholder?: string
|
|
66
|
+
autoFocus?: boolean
|
|
67
|
+
onEnter?: () => void
|
|
68
|
+
}) {
|
|
69
|
+
return (
|
|
70
|
+
<label className={styles.rtePopField}>
|
|
71
|
+
<span>{label}</span>
|
|
72
|
+
<input
|
|
73
|
+
value={value}
|
|
74
|
+
autoFocus={autoFocus}
|
|
75
|
+
placeholder={placeholder}
|
|
76
|
+
onChange={event => onChange(event.target.value)}
|
|
77
|
+
onKeyDown={event => {
|
|
78
|
+
if (event.key === 'Enter' && onEnter) {
|
|
79
|
+
event.preventDefault()
|
|
80
|
+
onEnter()
|
|
81
|
+
}
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
</label>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function LinkPopover({ editor, close }: { editor: Editor; close: () => void }) {
|
|
89
|
+
const active = editor.isActive('link')
|
|
90
|
+
const [href, setHref] = useState(() => (editor.getAttributes('link').href as string) ?? '')
|
|
91
|
+
const [text, setText] = useState(() => {
|
|
92
|
+
const { from, to } = editor.state.selection
|
|
93
|
+
return from === to ? '' : editor.state.doc.textBetween(from, to, ' ')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const apply = () => {
|
|
97
|
+
const url = safeHref(href)
|
|
98
|
+
if (!url) return
|
|
99
|
+
const label = text.trim()
|
|
100
|
+
const chain = editor.chain().focus().extendMarkRange('link')
|
|
101
|
+
// With no selection there is nothing to mark, so the label (or the address) becomes the text.
|
|
102
|
+
if (label || editor.state.selection.empty) {
|
|
103
|
+
chain.insertContent({ type: 'text', text: label || url, marks: [{ type: 'link', attrs: { href: url } }] }).run()
|
|
104
|
+
} else {
|
|
105
|
+
chain.setLink({ href: url }).run()
|
|
106
|
+
}
|
|
107
|
+
close()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={styles.rtePop} role="dialog" aria-label="Insert link">
|
|
112
|
+
<Field label="Text to display" value={text} onChange={setText} placeholder="Our policy page" autoFocus onEnter={apply} />
|
|
113
|
+
<Field label="Address" value={href} onChange={setHref} placeholder="example.com" onEnter={apply} />
|
|
114
|
+
<div className={styles.rtePopActions}>
|
|
115
|
+
{active && (
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
className={styles.rtePopBtn}
|
|
119
|
+
onClick={() => {
|
|
120
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
121
|
+
close()
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
Remove link
|
|
125
|
+
</button>
|
|
126
|
+
)}
|
|
127
|
+
<span className={styles.rtePopSpacer} />
|
|
128
|
+
<button type="button" className={styles.rtePopBtn} onClick={close}>
|
|
129
|
+
Cancel
|
|
130
|
+
</button>
|
|
131
|
+
<button type="button" className={styles.rtePopBtnPrimary} disabled={!safeHref(href)} onClick={apply}>
|
|
132
|
+
{active ? 'Update' : 'Insert'}
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function ImagePopover({
|
|
140
|
+
editor,
|
|
141
|
+
close,
|
|
142
|
+
upload,
|
|
143
|
+
}: {
|
|
144
|
+
editor: Editor
|
|
145
|
+
close: () => void
|
|
146
|
+
upload?: (file: File) => Promise<string>
|
|
147
|
+
}) {
|
|
148
|
+
const [src, setSrc] = useState('')
|
|
149
|
+
const [alt, setAlt] = useState('')
|
|
150
|
+
const [busy, setBusy] = useState(false)
|
|
151
|
+
const [error, setError] = useState('')
|
|
152
|
+
|
|
153
|
+
const apply = (address = src) => {
|
|
154
|
+
const url = safeHref(address)
|
|
155
|
+
if (!url) return
|
|
156
|
+
editor.chain().focus().setImage({ src: url, alt: alt.trim() || undefined }).run()
|
|
157
|
+
close()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const pick = async (file: File) => {
|
|
161
|
+
if (!upload) return
|
|
162
|
+
setBusy(true)
|
|
163
|
+
setError('')
|
|
164
|
+
try {
|
|
165
|
+
const url = await upload(file)
|
|
166
|
+
setSrc(url)
|
|
167
|
+
editor.chain().focus().setImage({ src: url, alt: alt.trim() || undefined }).run()
|
|
168
|
+
close()
|
|
169
|
+
} catch (err) {
|
|
170
|
+
setError(err instanceof Error ? err.message : 'That image could not be uploaded.')
|
|
171
|
+
} finally {
|
|
172
|
+
setBusy(false)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className={styles.rtePop} role="dialog" aria-label="Insert image">
|
|
178
|
+
{upload && (
|
|
179
|
+
<>
|
|
180
|
+
<label className={`${styles.rtePopUpload} ${busy ? styles.rtePopUploadBusy : ''}`}>
|
|
181
|
+
{busy ? 'Uploading…' : 'Choose a file…'}
|
|
182
|
+
<input
|
|
183
|
+
type="file"
|
|
184
|
+
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
|
185
|
+
disabled={busy}
|
|
186
|
+
onChange={event => {
|
|
187
|
+
const file = event.target.files?.[0]
|
|
188
|
+
event.target.value = ''
|
|
189
|
+
if (file) void pick(file)
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
</label>
|
|
193
|
+
<div className={styles.rtePopOr}>or paste an address</div>
|
|
194
|
+
</>
|
|
195
|
+
)}
|
|
196
|
+
<Field label="Address" value={src} onChange={setSrc} placeholder="https://…/photo.png" autoFocus={!upload} onEnter={() => apply()} />
|
|
197
|
+
<Field label="Description" value={alt} onChange={setAlt} placeholder="Shown if the image cannot load" onEnter={() => apply()} />
|
|
198
|
+
{error && <p className={styles.rtePopError}>{error}</p>}
|
|
199
|
+
<div className={styles.rtePopActions}>
|
|
200
|
+
<span className={styles.rtePopSpacer} />
|
|
201
|
+
<button type="button" className={styles.rtePopBtn} onClick={close}>
|
|
202
|
+
Cancel
|
|
203
|
+
</button>
|
|
204
|
+
<button type="button" className={styles.rtePopBtnPrimary} disabled={!safeHref(src) || busy} onClick={() => apply()}>
|
|
205
|
+
Insert
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const GRID = 6
|
|
213
|
+
|
|
214
|
+
function TablePopover({ editor, close }: { editor: Editor; close: () => void }) {
|
|
215
|
+
const [hover, setHover] = useState({ rows: 0, cols: 0 })
|
|
216
|
+
const [header, setHeader] = useState(true)
|
|
217
|
+
|
|
218
|
+
const insert = (rows: number, cols: number) => {
|
|
219
|
+
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: header }).run()
|
|
220
|
+
close()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className={styles.rtePop} role="dialog" aria-label="Insert table">
|
|
225
|
+
<div className={styles.rteGrid} onMouseLeave={() => setHover({ rows: 0, cols: 0 })}>
|
|
226
|
+
{Array.from({ length: GRID }, (_, row) =>
|
|
227
|
+
Array.from({ length: GRID }, (_, col) => (
|
|
228
|
+
<button
|
|
229
|
+
key={`${row}-${col}`}
|
|
230
|
+
type="button"
|
|
231
|
+
aria-label={`${row + 1} by ${col + 1} table`}
|
|
232
|
+
className={`${styles.rteGridCell} ${row < hover.rows && col < hover.cols ? styles.rteGridCellOn : ''}`}
|
|
233
|
+
onMouseEnter={() => setHover({ rows: row + 1, cols: col + 1 })}
|
|
234
|
+
onFocus={() => setHover({ rows: row + 1, cols: col + 1 })}
|
|
235
|
+
onClick={() => insert(row + 1, col + 1)}
|
|
236
|
+
/>
|
|
237
|
+
)),
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
<div className={styles.rteGridLabel}>{hover.rows ? `${hover.rows} × ${hover.cols}` : 'Pick a size'}</div>
|
|
241
|
+
<label className={styles.rtePopCheck}>
|
|
242
|
+
<input type="checkbox" checked={header} onChange={event => setHeader(event.target.checked)} />
|
|
243
|
+
<span>Header row</span>
|
|
244
|
+
</label>
|
|
245
|
+
</div>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Tiptap's image keeps only src and alt, so any width or border a signature carries was
|
|
251
|
+
* dropped the moment it passed through the editor. Preserving the attributes is what makes
|
|
252
|
+
* the image editable at all.
|
|
253
|
+
*/
|
|
254
|
+
const StyledImage = Image.extend({
|
|
255
|
+
addNodeView() {
|
|
256
|
+
return ReactNodeViewRenderer(ImageView)
|
|
257
|
+
},
|
|
258
|
+
renderHTML({ HTMLAttributes }) {
|
|
259
|
+
const { href, ...rest } = HTMLAttributes as Record<string, unknown> & { href?: string | null }
|
|
260
|
+
const image: [string, Record<string, unknown>] = ['img', rest]
|
|
261
|
+
if (!href) return image
|
|
262
|
+
const anchor: [string, Record<string, unknown>, typeof image] = [
|
|
263
|
+
'a',
|
|
264
|
+
{ href, target: '_blank', rel: 'noopener noreferrer' },
|
|
265
|
+
image,
|
|
266
|
+
]
|
|
267
|
+
return anchor
|
|
268
|
+
},
|
|
269
|
+
addAttributes() {
|
|
270
|
+
return {
|
|
271
|
+
...this.parent?.(),
|
|
272
|
+
href: {
|
|
273
|
+
default: null,
|
|
274
|
+
// Read off the anchor the image was saved inside, since the node itself is the image.
|
|
275
|
+
parseHTML: element => element.closest('a')?.getAttribute('href') ?? null,
|
|
276
|
+
renderHTML: attributes => (attributes.href ? { href: attributes.href } : {}),
|
|
277
|
+
},
|
|
278
|
+
align: {
|
|
279
|
+
default: null,
|
|
280
|
+
parseHTML: element => element.getAttribute('data-align'),
|
|
281
|
+
renderHTML: attributes => (attributes.align ? { 'data-align': attributes.align } : {}),
|
|
282
|
+
},
|
|
283
|
+
width: {
|
|
284
|
+
default: null,
|
|
285
|
+
parseHTML: element => element.getAttribute('width'),
|
|
286
|
+
renderHTML: attributes => (attributes.width ? { width: attributes.width } : {}),
|
|
287
|
+
},
|
|
288
|
+
height: {
|
|
289
|
+
default: null,
|
|
290
|
+
parseHTML: element => element.getAttribute('height'),
|
|
291
|
+
renderHTML: attributes => (attributes.height ? { height: attributes.height } : {}),
|
|
292
|
+
},
|
|
293
|
+
style: {
|
|
294
|
+
default: null,
|
|
295
|
+
parseHTML: element => element.getAttribute('style'),
|
|
296
|
+
renderHTML: attributes => (attributes.style ? { style: attributes.style } : {}),
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
function stylePairs(value: string | null | undefined): Record<string, string> {
|
|
303
|
+
return Object.fromEntries(
|
|
304
|
+
(value ?? '')
|
|
305
|
+
.split(';')
|
|
306
|
+
.map(part => part.split(':'))
|
|
307
|
+
.filter(pair => pair.length === 2)
|
|
308
|
+
.map(([name, entry]) => [name.trim(), entry.trim()]),
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function styleText(pairs: Record<string, string>): string {
|
|
313
|
+
return Object.entries(pairs)
|
|
314
|
+
.filter(([, entry]) => entry)
|
|
315
|
+
.map(([name, entry]) => `${name}:${entry}`)
|
|
316
|
+
.join(';')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const GRIPS = [
|
|
320
|
+
{ corner: 'nw', direction: -1, className: 'rteImageGripNW' },
|
|
321
|
+
{ corner: 'ne', direction: 1, className: 'rteImageGripNE' },
|
|
322
|
+
{ corner: 'sw', direction: -1, className: 'rteImageGripSW' },
|
|
323
|
+
{ corner: 'se', direction: 1, className: 'rteImageGripSE' },
|
|
324
|
+
] as const
|
|
325
|
+
|
|
326
|
+
const ALIGNMENTS = [
|
|
327
|
+
{ key: 'left', label: 'Align left', glyph: '\u21e4' },
|
|
328
|
+
{ key: 'center', label: 'Centre', glyph: '\u2194' },
|
|
329
|
+
{ key: 'right', label: 'Align right', glyph: '\u21e5' },
|
|
330
|
+
] as const
|
|
331
|
+
|
|
332
|
+
function reactStyle(pairs: Record<string, string>): React.CSSProperties {
|
|
333
|
+
return Object.fromEntries(
|
|
334
|
+
Object.entries(pairs).map(([name, value]) => [name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()), value]),
|
|
335
|
+
) as React.CSSProperties
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The image is edited where it sits: selected with an outline, resized by its corners, and
|
|
340
|
+
* given a border, an alignment or a link from a bar anchored to it. A row of number boxes at
|
|
341
|
+
* the top of the toolbar was correct and undiscoverable.
|
|
342
|
+
*/
|
|
343
|
+
function ImageView({ node, updateAttributes, selected, editor }: NodeViewProps) {
|
|
344
|
+
const attributes = node.attrs as {
|
|
345
|
+
src: string
|
|
346
|
+
alt: string | null
|
|
347
|
+
width: string | null
|
|
348
|
+
style: string | null
|
|
349
|
+
href: string | null
|
|
350
|
+
align: string | null
|
|
351
|
+
}
|
|
352
|
+
const declared = stylePairs(attributes.style)
|
|
353
|
+
const framed = Boolean(declared.border) && !['none', '0', '0px'].includes(declared.border)
|
|
354
|
+
const radius = parseInt(declared['border-radius'] ?? '', 10)
|
|
355
|
+
const align = attributes.align ?? 'left'
|
|
356
|
+
const imageRef = useRef<HTMLImageElement | null>(null)
|
|
357
|
+
const [dragWidth, setDragWidth] = useState<number | null>(null)
|
|
358
|
+
const [linkOpen, setLinkOpen] = useState(false)
|
|
359
|
+
const [href, setHref] = useState(attributes.href ?? '')
|
|
360
|
+
const width = dragWidth ?? parseInt(String(declared.width ?? attributes.width ?? ''), 10)
|
|
361
|
+
const live = Boolean(selected && editor.isEditable)
|
|
362
|
+
|
|
363
|
+
// A mail client reads the width attribute; a browser reads the style. Write both.
|
|
364
|
+
const write = (patch: Record<string, string>) => {
|
|
365
|
+
const next = { ...declared, ...patch }
|
|
366
|
+
const pixels = parseInt(next.width ?? '', 10)
|
|
367
|
+
updateAttributes({ style: styleText(next) || null, width: Number.isFinite(pixels) ? String(pixels) : null })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// One history entry per drag rather than one per pixel: the width is held here while the
|
|
371
|
+
// pointer is down and committed once it is released.
|
|
372
|
+
const startResize = (event: React.PointerEvent<HTMLSpanElement>, direction: 1 | -1) => {
|
|
373
|
+
event.preventDefault()
|
|
374
|
+
event.stopPropagation()
|
|
375
|
+
const handle = event.currentTarget
|
|
376
|
+
handle.setPointerCapture(event.pointerId)
|
|
377
|
+
const startX = event.clientX
|
|
378
|
+
const startWidth = imageRef.current?.getBoundingClientRect().width ?? 200
|
|
379
|
+
let latest = Math.round(startWidth)
|
|
380
|
+
const move = (moveEvent: PointerEvent) => {
|
|
381
|
+
latest = Math.round(Math.min(800, Math.max(24, startWidth + direction * (moveEvent.clientX - startX))))
|
|
382
|
+
setDragWidth(latest)
|
|
383
|
+
}
|
|
384
|
+
const stop = () => {
|
|
385
|
+
handle.removeEventListener('pointermove', move)
|
|
386
|
+
handle.removeEventListener('pointerup', stop)
|
|
387
|
+
handle.removeEventListener('pointercancel', stop)
|
|
388
|
+
setDragWidth(null)
|
|
389
|
+
write({ width: `${latest}px`, height: 'auto' })
|
|
390
|
+
}
|
|
391
|
+
handle.addEventListener('pointermove', move)
|
|
392
|
+
handle.addEventListener('pointerup', stop)
|
|
393
|
+
handle.addEventListener('pointercancel', stop)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const applyLink = () => {
|
|
397
|
+
const target = href.trim() ? safeHref(href) : null
|
|
398
|
+
if (href.trim() && !target) return
|
|
399
|
+
updateAttributes({ href: target })
|
|
400
|
+
setLinkOpen(false)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
<NodeViewWrapper className={styles.rteImageWrap} data-align={align}>
|
|
405
|
+
<span className={`${styles.rteImageBox} ${live ? styles.rteImageBoxOn : ''}`}>
|
|
406
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
407
|
+
<img
|
|
408
|
+
ref={imageRef}
|
|
409
|
+
src={attributes.src}
|
|
410
|
+
alt={attributes.alt ?? ''}
|
|
411
|
+
draggable={false}
|
|
412
|
+
style={{ ...reactStyle(declared), ...(dragWidth ? { width: `${dragWidth}px`, height: 'auto' } : {}) }}
|
|
413
|
+
/>
|
|
414
|
+
{live &&
|
|
415
|
+
GRIPS.map(grip => (
|
|
416
|
+
<span
|
|
417
|
+
key={grip.corner}
|
|
418
|
+
className={`${styles.rteImageGrip} ${styles[grip.className]}`}
|
|
419
|
+
role="presentation"
|
|
420
|
+
onPointerDown={event => startResize(event, grip.direction)}
|
|
421
|
+
/>
|
|
422
|
+
))}
|
|
423
|
+
</span>
|
|
424
|
+
{live && (
|
|
425
|
+
<div className={styles.rteImageBar} contentEditable={false}>
|
|
426
|
+
<span className={styles.rteGridLabel}>W</span>
|
|
427
|
+
<input
|
|
428
|
+
className={styles.rteSizeSelect}
|
|
429
|
+
type="number"
|
|
430
|
+
min={24}
|
|
431
|
+
max={800}
|
|
432
|
+
step={10}
|
|
433
|
+
title="Width in pixels"
|
|
434
|
+
aria-label="Image width in pixels"
|
|
435
|
+
value={Number.isFinite(width) ? width : ''}
|
|
436
|
+
onChange={event => write({ width: event.target.value ? `${event.target.value}px` : '', height: 'auto' })}
|
|
437
|
+
/>
|
|
438
|
+
{ALIGNMENTS.map(option => (
|
|
439
|
+
<Btn
|
|
440
|
+
key={option.key}
|
|
441
|
+
title={option.label}
|
|
442
|
+
active={align === option.key}
|
|
443
|
+
onClick={() =>
|
|
444
|
+
updateAttributes({
|
|
445
|
+
align: option.key,
|
|
446
|
+
style: styleText({
|
|
447
|
+
...declared,
|
|
448
|
+
'margin-left': option.key === 'left' ? '' : 'auto',
|
|
449
|
+
'margin-right': option.key === 'right' ? '' : 'auto',
|
|
450
|
+
}),
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
>
|
|
454
|
+
{option.glyph}
|
|
455
|
+
</Btn>
|
|
456
|
+
))}
|
|
457
|
+
<Btn
|
|
458
|
+
title={framed ? 'Remove the border' : 'Add a border'}
|
|
459
|
+
active={framed}
|
|
460
|
+
onClick={() =>
|
|
461
|
+
write(
|
|
462
|
+
framed
|
|
463
|
+
? { border: '', padding: '', 'border-radius': '' }
|
|
464
|
+
: { border: `1px solid ${CLIENT_BRAND.accent}`, padding: '8px', 'border-radius': '8px' },
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
wide
|
|
468
|
+
>
|
|
469
|
+
Border
|
|
470
|
+
</Btn>
|
|
471
|
+
{framed && (
|
|
472
|
+
<>
|
|
473
|
+
<span className={styles.rteGridLabel}>R</span>
|
|
474
|
+
<input
|
|
475
|
+
className={styles.rteSizeSelect}
|
|
476
|
+
type="number"
|
|
477
|
+
min={0}
|
|
478
|
+
max={200}
|
|
479
|
+
title="Corner radius in pixels"
|
|
480
|
+
aria-label="Corner radius in pixels"
|
|
481
|
+
value={Number.isFinite(radius) ? radius : 0}
|
|
482
|
+
onChange={event => write({ 'border-radius': `${event.target.value || 0}px` })}
|
|
483
|
+
/>
|
|
484
|
+
{[...new Set([CLIENT_BRAND.accent, ...TEXT_COLOURS])].map(colour => (
|
|
485
|
+
<button
|
|
486
|
+
key={colour}
|
|
487
|
+
type="button"
|
|
488
|
+
className={styles.rteSwatch}
|
|
489
|
+
style={{ background: colour }}
|
|
490
|
+
title={`Border colour ${colour}`}
|
|
491
|
+
aria-label={`Border colour ${colour}`}
|
|
492
|
+
onMouseDown={event => event.preventDefault()}
|
|
493
|
+
onClick={() => write({ border: `1px solid ${colour}` })}
|
|
494
|
+
/>
|
|
495
|
+
))}
|
|
496
|
+
<label className={styles.rteSwatchCustom} title="Any other border colour">
|
|
497
|
+
<input
|
|
498
|
+
type="color"
|
|
499
|
+
aria-label="Choose any border colour"
|
|
500
|
+
onChange={event => write({ border: `1px solid ${event.target.value}` })}
|
|
501
|
+
/>
|
|
502
|
+
</label>
|
|
503
|
+
</>
|
|
504
|
+
)}
|
|
505
|
+
<Btn title="Link this image" active={Boolean(attributes.href) || linkOpen} onClick={() => setLinkOpen(open => !open)} wide>
|
|
506
|
+
Link
|
|
507
|
+
</Btn>
|
|
508
|
+
{linkOpen && (
|
|
509
|
+
<>
|
|
510
|
+
<input
|
|
511
|
+
className={styles.rteImageLink}
|
|
512
|
+
value={href}
|
|
513
|
+
placeholder={CLIENT_BRAND.websiteUrl}
|
|
514
|
+
aria-label="Address this image opens"
|
|
515
|
+
onChange={event => setHref(event.target.value)}
|
|
516
|
+
onKeyDown={event => {
|
|
517
|
+
if (event.key === 'Enter') applyLink()
|
|
518
|
+
}}
|
|
519
|
+
/>
|
|
520
|
+
<Btn
|
|
521
|
+
title="Use the company website"
|
|
522
|
+
onClick={() => {
|
|
523
|
+
setHref(CLIENT_BRAND.websiteUrl)
|
|
524
|
+
updateAttributes({ href: CLIENT_BRAND.websiteUrl })
|
|
525
|
+
setLinkOpen(false)
|
|
526
|
+
}}
|
|
527
|
+
wide
|
|
528
|
+
>
|
|
529
|
+
Website
|
|
530
|
+
</Btn>
|
|
531
|
+
<Btn title="Apply the address" onClick={applyLink} wide>
|
|
532
|
+
Apply
|
|
533
|
+
</Btn>
|
|
534
|
+
{attributes.href && (
|
|
535
|
+
<Btn
|
|
536
|
+
title="Remove the link"
|
|
537
|
+
onClick={() => {
|
|
538
|
+
setHref('')
|
|
539
|
+
updateAttributes({ href: null })
|
|
540
|
+
setLinkOpen(false)
|
|
541
|
+
}}
|
|
542
|
+
wide
|
|
543
|
+
>
|
|
544
|
+
Unlink
|
|
545
|
+
</Btn>
|
|
546
|
+
)}
|
|
547
|
+
</>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
551
|
+
</NodeViewWrapper>
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function TableTools({ editor }: { editor: Editor }) {
|
|
556
|
+
return (
|
|
557
|
+
<Group>
|
|
558
|
+
<Btn title="Add row" onClick={() => editor.chain().focus().addRowAfter().run()} wide>
|
|
559
|
+
+Row
|
|
560
|
+
</Btn>
|
|
561
|
+
<Btn title="Add column" onClick={() => editor.chain().focus().addColumnAfter().run()} wide>
|
|
562
|
+
+Col
|
|
563
|
+
</Btn>
|
|
564
|
+
<Btn title="Delete row" onClick={() => editor.chain().focus().deleteRow().run()} wide>
|
|
565
|
+
−Row
|
|
566
|
+
</Btn>
|
|
567
|
+
<Btn title="Delete column" onClick={() => editor.chain().focus().deleteColumn().run()} wide>
|
|
568
|
+
−Col
|
|
569
|
+
</Btn>
|
|
570
|
+
<Btn title="Delete table" onClick={() => editor.chain().focus().deleteTable().run()}>
|
|
571
|
+
🗑
|
|
572
|
+
</Btn>
|
|
573
|
+
</Group>
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
type Pop = 'link' | 'image' | 'table' | null
|
|
578
|
+
|
|
579
|
+
function Toolbar({ editor, uploadImage, fonts = [] }: { editor: Editor; uploadImage?: (file: File) => Promise<string>; fonts?: string[] }) {
|
|
580
|
+
const [pop, setPop] = useState<Pop>(null)
|
|
581
|
+
const popRef = useRef<HTMLDivElement>(null)
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
if (!pop) return
|
|
585
|
+
const onDown = (event: MouseEvent) => {
|
|
586
|
+
if (!popRef.current?.contains(event.target as Node)) setPop(null)
|
|
587
|
+
}
|
|
588
|
+
const onKey = (event: KeyboardEvent) => {
|
|
589
|
+
if (event.key === 'Escape') setPop(null)
|
|
590
|
+
}
|
|
591
|
+
document.addEventListener('mousedown', onDown)
|
|
592
|
+
document.addEventListener('keydown', onKey)
|
|
593
|
+
return () => {
|
|
594
|
+
document.removeEventListener('mousedown', onDown)
|
|
595
|
+
document.removeEventListener('keydown', onKey)
|
|
596
|
+
}
|
|
597
|
+
}, [pop])
|
|
598
|
+
|
|
599
|
+
const openLink = useCallback(() => {
|
|
600
|
+
// Collapsed caret inside an existing link: widen to the whole link so its text and
|
|
601
|
+
// address can be read out and edited as a unit.
|
|
602
|
+
if (editor.isActive('link') && editor.state.selection.empty) {
|
|
603
|
+
editor.chain().extendMarkRange('link').run()
|
|
604
|
+
}
|
|
605
|
+
setPop(current => (current === 'link' ? null : 'link'))
|
|
606
|
+
}, [editor])
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
<div className={styles.rteToolbar} role="toolbar" aria-label="Formatting">
|
|
610
|
+
<Group>
|
|
611
|
+
<MailSelect
|
|
612
|
+
buttonClassName={styles.rteSelect}
|
|
613
|
+
ariaLabel="Font"
|
|
614
|
+
placeholder="Font"
|
|
615
|
+
value={(editor.getAttributes('textStyle').fontFamily as string | undefined)?.replace(/^['"]|['"]$/g, '') ?? ''}
|
|
616
|
+
options={[{ value: '', label: 'Default font' }, ...[...BUILTIN_FONTS, ...fonts.filter(font => !BUILTIN_FONTS.includes(font))].map(font => ({ value: font, label: font }))]}
|
|
617
|
+
optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
|
|
618
|
+
onChange={family => {
|
|
619
|
+
if (family) editor.chain().focus().setFontFamily(family).run()
|
|
620
|
+
else editor.chain().focus().unsetFontFamily().run()
|
|
621
|
+
}}
|
|
622
|
+
/>
|
|
623
|
+
<MailSelect
|
|
624
|
+
buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
|
|
625
|
+
ariaLabel="Font size"
|
|
626
|
+
placeholder="Size"
|
|
627
|
+
editable
|
|
628
|
+
value={(editor.getAttributes('textStyle').fontSize as string | undefined) ?? ''}
|
|
629
|
+
options={[{ value: '', label: 'Default' }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
|
|
630
|
+
onChange={raw => {
|
|
631
|
+
const size = /^\d+(\.\d+)?$/.test(raw) ? `${raw}px` : raw
|
|
632
|
+
if (!size || size === 'Default') editor.chain().focus().unsetFontSize().run()
|
|
633
|
+
else if (/^\d+(\.\d+)?(px|pt|em|rem|%)$/.test(size)) editor.chain().focus().setFontSize(size).run()
|
|
634
|
+
}}
|
|
635
|
+
/>
|
|
636
|
+
</Group>
|
|
637
|
+
|
|
638
|
+
<Group>
|
|
639
|
+
<Btn title="Bold" active={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}>
|
|
640
|
+
<strong>B</strong>
|
|
641
|
+
</Btn>
|
|
642
|
+
<Btn title="Italic" active={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}>
|
|
643
|
+
<em>I</em>
|
|
644
|
+
</Btn>
|
|
645
|
+
<Btn title="Underline" active={editor.isActive('underline')} onClick={() => editor.chain().focus().toggleUnderline().run()}>
|
|
646
|
+
<u>U</u>
|
|
647
|
+
</Btn>
|
|
648
|
+
<Btn title="Strikethrough" active={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}>
|
|
649
|
+
<s>S</s>
|
|
650
|
+
</Btn>
|
|
651
|
+
</Group>
|
|
652
|
+
|
|
653
|
+
<Group>
|
|
654
|
+
<span className={styles.rteSwatches} role="group" aria-label="Text colour">
|
|
655
|
+
{TEXT_COLOURS.map(colour => (
|
|
656
|
+
<button
|
|
657
|
+
key={colour}
|
|
658
|
+
type="button"
|
|
659
|
+
title={`Text ${colour}`}
|
|
660
|
+
aria-label={`Text colour ${colour}`}
|
|
661
|
+
className={styles.rteSwatch}
|
|
662
|
+
style={{ background: colour }}
|
|
663
|
+
onMouseDown={event => event.preventDefault()}
|
|
664
|
+
onClick={() => editor.chain().focus().setColor(colour).run()}
|
|
665
|
+
/>
|
|
666
|
+
))}
|
|
667
|
+
<label className={styles.rteSwatchCustom} title="Any other colour">
|
|
668
|
+
<input
|
|
669
|
+
type="color"
|
|
670
|
+
aria-label="Choose any text colour"
|
|
671
|
+
onChange={event => editor.chain().focus().setColor(event.target.value).run()}
|
|
672
|
+
/>
|
|
673
|
+
</label>
|
|
674
|
+
</span>
|
|
675
|
+
<span className={styles.rteSwatches} role="group" aria-label="Highlight">
|
|
676
|
+
{HIGHLIGHTS.map(colour => (
|
|
677
|
+
<button
|
|
678
|
+
key={colour}
|
|
679
|
+
type="button"
|
|
680
|
+
title={`Highlight ${colour}`}
|
|
681
|
+
aria-label={`Highlight ${colour}`}
|
|
682
|
+
className={`${styles.rteSwatch} ${styles.rteSwatchHi}`}
|
|
683
|
+
style={{ background: colour }}
|
|
684
|
+
onMouseDown={event => event.preventDefault()}
|
|
685
|
+
onClick={() => editor.chain().focus().toggleHighlight({ color: colour }).run()}
|
|
686
|
+
/>
|
|
687
|
+
))}
|
|
688
|
+
</span>
|
|
689
|
+
<Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}>
|
|
690
|
+
✕
|
|
691
|
+
</Btn>
|
|
692
|
+
</Group>
|
|
693
|
+
|
|
694
|
+
<Group>
|
|
695
|
+
<Btn title="Bullet list" active={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}>
|
|
696
|
+
••
|
|
697
|
+
</Btn>
|
|
698
|
+
<Btn title="Numbered list" active={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}>
|
|
699
|
+
1.
|
|
700
|
+
</Btn>
|
|
701
|
+
<Btn title="Quote" active={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}>
|
|
702
|
+
❝
|
|
703
|
+
</Btn>
|
|
704
|
+
<Btn title="Code" active={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
|
|
705
|
+
{'</>'}
|
|
706
|
+
</Btn>
|
|
707
|
+
</Group>
|
|
708
|
+
|
|
709
|
+
<Group>
|
|
710
|
+
{(['left', 'center', 'right', 'justify'] as const).map(align => (
|
|
711
|
+
<Btn
|
|
712
|
+
key={align}
|
|
713
|
+
title={`Align ${align}`}
|
|
714
|
+
active={editor.isActive({ textAlign: align })}
|
|
715
|
+
onClick={() => editor.chain().focus().setTextAlign(align).run()}
|
|
716
|
+
>
|
|
717
|
+
{align === 'left' ? '⇤' : align === 'center' ? '↔' : align === 'right' ? '⇥' : '≡'}
|
|
718
|
+
</Btn>
|
|
719
|
+
))}
|
|
720
|
+
</Group>
|
|
721
|
+
|
|
722
|
+
<div className={styles.rtePopWrap} ref={popRef}>
|
|
723
|
+
<Group>
|
|
724
|
+
<Btn title="Insert link" active={editor.isActive('link') || pop === 'link'} onClick={openLink} wide>
|
|
725
|
+
Link
|
|
726
|
+
</Btn>
|
|
727
|
+
<Btn title="Insert image" active={pop === 'image'} onClick={() => setPop(current => (current === 'image' ? null : 'image'))} wide>
|
|
728
|
+
Image
|
|
729
|
+
</Btn>
|
|
730
|
+
<Btn title="Insert table" active={pop === 'table'} onClick={() => setPop(current => (current === 'table' ? null : 'table'))} wide>
|
|
731
|
+
Table
|
|
732
|
+
</Btn>
|
|
733
|
+
<Btn title="Divider" onClick={() => editor.chain().focus().setHorizontalRule().run()}>
|
|
734
|
+
—
|
|
735
|
+
</Btn>
|
|
736
|
+
</Group>
|
|
737
|
+
{pop === 'link' && <LinkPopover editor={editor} close={() => setPop(null)} />}
|
|
738
|
+
{pop === 'image' && <ImagePopover editor={editor} close={() => setPop(null)} upload={uploadImage} />}
|
|
739
|
+
{pop === 'table' && <TablePopover editor={editor} close={() => setPop(null)} />}
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
{editor.isActive('table') && <TableTools editor={editor} />}
|
|
743
|
+
|
|
744
|
+
<Group>
|
|
745
|
+
<Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}>
|
|
746
|
+
↺
|
|
747
|
+
</Btn>
|
|
748
|
+
<Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}>
|
|
749
|
+
↻
|
|
750
|
+
</Btn>
|
|
751
|
+
</Group>
|
|
752
|
+
</div>
|
|
753
|
+
)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export default function RichEditor({
|
|
757
|
+
html,
|
|
758
|
+
onChange,
|
|
759
|
+
placeholder,
|
|
760
|
+
uploadImage,
|
|
761
|
+
fonts,
|
|
762
|
+
fontFaceCss,
|
|
763
|
+
baseFont,
|
|
764
|
+
}: {
|
|
765
|
+
html: string
|
|
766
|
+
onChange: (html: string) => void
|
|
767
|
+
placeholder?: string
|
|
768
|
+
uploadImage?: (file: File) => Promise<string>
|
|
769
|
+
fonts?: string[]
|
|
770
|
+
fontFaceCss?: string
|
|
771
|
+
baseFont?: BaseFont
|
|
772
|
+
}) {
|
|
773
|
+
const uploadRef = useRef(uploadImage)
|
|
774
|
+
const editorRef = useRef<Editor | null>(null)
|
|
775
|
+
useEffect(() => {
|
|
776
|
+
uploadRef.current = uploadImage
|
|
777
|
+
}, [uploadImage])
|
|
778
|
+
|
|
779
|
+
// A dropped or pasted image file is uploaded and inserted by URL. Left to the browser,
|
|
780
|
+
// Chrome on Windows inserts it as file:///C:/… — which renders for nobody.
|
|
781
|
+
const placeImages = (files: File[], pos?: number): boolean => {
|
|
782
|
+
const images = files.filter(file => file.type.startsWith('image/'))
|
|
783
|
+
if (!images.length) return false
|
|
784
|
+
const upload = uploadRef.current
|
|
785
|
+
if (!upload) return true
|
|
786
|
+
void (async () => {
|
|
787
|
+
for (const file of images) {
|
|
788
|
+
try {
|
|
789
|
+
const src = await upload(file)
|
|
790
|
+
const chain = editorRef.current?.chain().focus()
|
|
791
|
+
if (!chain) return
|
|
792
|
+
if (pos !== undefined) chain.setTextSelection(pos)
|
|
793
|
+
chain.setImage({ src }).run()
|
|
794
|
+
} catch (err) {
|
|
795
|
+
console.warn('[editor] image upload failed', err)
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
})()
|
|
799
|
+
return true
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const editor = useEditor({
|
|
803
|
+
// Rendered on the client only: the editor touches the DOM on creation and would
|
|
804
|
+
// otherwise mismatch the server-rendered markup.
|
|
805
|
+
immediatelyRender: false,
|
|
806
|
+
extensions: [
|
|
807
|
+
// StarterKit ships its own link and underline; ours replace them so
|
|
808
|
+
// openOnClick stays off and a link in the composer is editable, not followed.
|
|
809
|
+
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, underline: false }),
|
|
810
|
+
Underline,
|
|
811
|
+
TextStyle,
|
|
812
|
+
FontFamily,
|
|
813
|
+
FontSize,
|
|
814
|
+
Color,
|
|
815
|
+
Highlight.configure({ multicolor: true }),
|
|
816
|
+
Link.configure({ openOnClick: false, autolink: true }),
|
|
817
|
+
StyledImage.configure({ inline: false }),
|
|
818
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
819
|
+
Table.configure({ resizable: false }),
|
|
820
|
+
TableRow,
|
|
821
|
+
TableHeader,
|
|
822
|
+
TableCell,
|
|
823
|
+
],
|
|
824
|
+
content: html || '',
|
|
825
|
+
editorProps: {
|
|
826
|
+
attributes: { class: styles.rteSurface, 'aria-label': placeholder ?? 'Message body' },
|
|
827
|
+
handleDrop: (view, event) =>
|
|
828
|
+
placeImages(Array.from(event.dataTransfer?.files ?? []), view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos),
|
|
829
|
+
handlePaste: (_view, event) => placeImages(Array.from(event.clipboardData?.files ?? [])),
|
|
830
|
+
},
|
|
831
|
+
onUpdate: ({ editor: instance }) => onChange(instance.getHTML()),
|
|
832
|
+
})
|
|
833
|
+
|
|
834
|
+
// Only push content in when it changed elsewhere — writing on every keystroke would
|
|
835
|
+
// reset the cursor to the start of the document.
|
|
836
|
+
useEffect(() => {
|
|
837
|
+
if (!editor) return
|
|
838
|
+
// An empty document reads back as <p></p>, so compare against that rather than the
|
|
839
|
+
// empty string — otherwise every render while empty resets the caret.
|
|
840
|
+
if ((html || '<p></p>') !== editor.getHTML()) editor.commands.setContent(html || '', { emitUpdate: false })
|
|
841
|
+
}, [html, editor])
|
|
842
|
+
|
|
843
|
+
useEffect(() => {
|
|
844
|
+
editorRef.current = editor
|
|
845
|
+
}, [editor])
|
|
846
|
+
|
|
847
|
+
if (!editor) return <div className={styles.rteLoading} />
|
|
848
|
+
|
|
849
|
+
return (
|
|
850
|
+
<div
|
|
851
|
+
className={styles.rte}
|
|
852
|
+
style={{ '--rte-font': fontStack(baseFont?.family ?? ''), '--rte-size': baseFont?.size || '15px' } as React.CSSProperties}
|
|
853
|
+
>
|
|
854
|
+
{fontFaceCss && <style>{fontFaceCss}</style>}
|
|
855
|
+
<Toolbar editor={editor} uploadImage={uploadImage} fonts={fonts} />
|
|
856
|
+
<EditorContent editor={editor} className={styles.rteContent} />
|
|
857
|
+
</div>
|
|
858
|
+
)
|
|
859
|
+
}
|