@novacraft-engineering/mailbox 0.4.24 → 0.4.26

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 (42) hide show
  1. package/.env.example +8 -2
  2. package/app/api/mail/company-dictionary/route.ts +36 -0
  3. package/app/api/mail/company-signature/route.ts +1 -1
  4. package/app/api/mail/events/ses/route.ts +88 -0
  5. package/app/api/mail/grammar/route.ts +52 -0
  6. package/app/api/mail/scheduled/route.ts +15 -1
  7. package/app/mail/RichEditor.tsx +450 -5
  8. package/app/mail/page.module.css +275 -0
  9. package/app/mail/page.tsx +115 -12
  10. package/app/mail/tabKey.ts +52 -0
  11. package/app/mail/writing/WritingPanel.tsx +259 -0
  12. package/app/mail/writing/assist.ts +139 -0
  13. package/app/mail/writing/checks.test.ts +48 -0
  14. package/app/mail/writing/checks.ts +58 -0
  15. package/app/mail/writing/find.ts +52 -0
  16. package/app/mail/writing/grammar.ts +100 -0
  17. package/app/mail/writing/live.ts +34 -0
  18. package/app/mail/writing/paste.test.ts +17 -0
  19. package/app/mail/writing/paste.ts +30 -0
  20. package/app/mail/writing/settings.ts +98 -0
  21. package/app/mail/writing/spell.worker.ts +40 -0
  22. package/app/mail/writing/spelling.ts +164 -0
  23. package/app/mail/writing/templates.ts +40 -0
  24. package/app/mail/writing/text.test.ts +88 -0
  25. package/app/mail/writing/text.ts +106 -0
  26. package/lib/dev-auth.ts +5 -3
  27. package/lib/dmarc-report.test.ts +18 -0
  28. package/lib/mail-provider.ts +1 -2
  29. package/lib/mailbox.ts +7 -2
  30. package/lib/receive.test.ts +24 -0
  31. package/lib/receive.ts +55 -11
  32. package/lib/scheduled.ts +67 -33
  33. package/lib/ses-send.test.ts +21 -0
  34. package/lib/ses-send.ts +29 -5
  35. package/lib/sns.ts +68 -0
  36. package/package.json +3 -1
  37. package/public/dictionaries/en-GB.LICENSE +347 -0
  38. package/public/dictionaries/en-GB.aff +205 -0
  39. package/public/dictionaries/en-GB.dic +49602 -0
  40. package/public/dictionaries/en-US.LICENSE +347 -0
  41. package/public/dictionaries/en-US.aff +205 -0
  42. package/public/dictionaries/en-US.dic +49569 -0
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
4
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
4
5
  import { EditorContent, Extension, NodeViewWrapper, ReactNodeViewRenderer, useEditor, type Editor, type NodeViewProps } from '@tiptap/react'
5
6
  import StarterKit from '@tiptap/starter-kit'
6
7
  import Underline from '@tiptap/extension-underline'
@@ -16,6 +17,16 @@ import Highlight from '@tiptap/extension-highlight'
16
17
  import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
17
18
  import { safeHref } from '@/lib/email-html'
18
19
  import styles from './page.module.css'
20
+ import { TAB_TEXT, tabPress } from './tabKey'
21
+ import { WRITING_DEFAULTS, type WritingSettings } from './writing/settings'
22
+ import { WritingAssist, nextField } from './writing/assist'
23
+ import { fillTemplate, type MailTemplate, type TemplateContext } from './writing/templates'
24
+ import { Spelling, spellingKey, suggestSpelling } from './writing/spelling'
25
+ import { Grammar, grammarIgnoreKey, grammarKey } from './writing/grammar'
26
+ import { FindReplace, findKey, findMatches, findState } from './writing/find'
27
+ import { cleanPastedHtml } from './writing/paste'
28
+ import WritingPanel from './writing/WritingPanel'
29
+ import { LiveStore, live } from './writing/live'
19
30
 
20
31
  const TEXT_COLOURS = ['#030712', '#b91c1c', '#1d4ed8', '#047857', '#b45309', '#6d28d9', '#6b7280']
21
32
  const HIGHLIGHTS = ['#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#FED7AA']
@@ -589,7 +600,17 @@ function TableTools({ editor }: { editor: Editor }) {
589
600
 
590
601
  type Pop = 'link' | 'image' | 'table' | null
591
602
 
592
- function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor; uploadImage?: (file: File) => Promise<string>; fonts?: string[]; baseFont?: BaseFont }) {
603
+ function Toolbar({
604
+ editor,
605
+ uploadImage,
606
+ fonts = [],
607
+ baseFont,
608
+ }: {
609
+ editor: Editor
610
+ uploadImage?: (file: File) => Promise<string>
611
+ fonts?: string[]
612
+ baseFont?: BaseFont
613
+ }) {
593
614
  const [pop, setPop] = useState<Pop>(null)
594
615
  const popRef = useRef<HTMLDivElement>(null)
595
616
  const phone = usePhone()
@@ -624,6 +645,10 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
624
645
  setPop(current => (current === 'link' ? null : 'link'))
625
646
  }, [editor])
626
647
 
648
+ useEffect(() => {
649
+ live(editor).openLink = openLink
650
+ }, [editor, openLink])
651
+
627
652
  return (
628
653
  <>
629
654
  <div className={styles.rteToolbar} role="toolbar" aria-label="Formatting">
@@ -840,6 +865,82 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
840
865
  )
841
866
  }
842
867
 
868
+ type TabMark = { doc: ProseMirrorNode; undo: () => void } | null
869
+
870
+ /**
871
+ * Tab indents the body rather than leaving it; a list item is nested a level instead. A
872
+ * quick second Tab takes that indent back and lets focus move to the next control. Inside a
873
+ * table, Tab keeps moving between cells.
874
+ */
875
+ const TabKey = Extension.create<Record<string, never>, { memory: { at: number; mark: TabMark } }>({
876
+ name: 'tabKey',
877
+ addStorage() {
878
+ return { memory: { at: 0, mark: null } }
879
+ },
880
+ addKeyboardShortcuts() {
881
+ return {
882
+ Tab: () => {
883
+ const editor = this.editor
884
+ if (editor.isActive('table')) return false
885
+ const field = nextField(editor.state.doc, editor.state.selection.to)
886
+ if (field) {
887
+ editor.commands.setTextSelection(field)
888
+ return true
889
+ }
890
+ const settings = live(editor).settings
891
+ if (!settings.tabIndent) return false
892
+ const { leaving, previous } = tabPress(this.storage.memory, () => null, settings.doubleTabMs)
893
+ if (leaving) {
894
+ if (previous && previous.doc === editor.state.doc) previous.undo()
895
+ return false
896
+ }
897
+ let undo: () => void
898
+ if (editor.can().sinkListItem('listItem')) {
899
+ editor.commands.sinkListItem('listItem')
900
+ undo = () => editor.commands.liftListItem('listItem')
901
+ } else {
902
+ editor.commands.insertContent(TAB_TEXT)
903
+ const end = editor.state.selection.from
904
+ undo = () => editor.commands.deleteRange({ from: end - TAB_TEXT.length, to: end })
905
+ }
906
+ this.storage.memory.mark = { doc: editor.state.doc, undo }
907
+ return true
908
+ },
909
+ 'Shift-Tab': () => {
910
+ const editor = this.editor
911
+ if (editor.isActive('table')) return false
912
+ return editor.can().liftListItem('listItem') ? editor.commands.liftListItem('listItem') : false
913
+ },
914
+ }
915
+ },
916
+ })
917
+
918
+ const ComposerShortcuts = Extension.create({
919
+ name: 'composerShortcuts',
920
+ addKeyboardShortcuts() {
921
+ return {
922
+ 'Mod-Enter': () => {
923
+ const submit = live(this.editor).submit
924
+ if (!submit) return false
925
+ submit()
926
+ return true
927
+ },
928
+ 'Mod-k': () => {
929
+ const openLink = live(this.editor).openLink
930
+ if (!openLink) return false
931
+ openLink()
932
+ return true
933
+ },
934
+ 'Mod-f': () => {
935
+ const openFind = live(this.editor).openFind
936
+ if (!openFind) return false
937
+ openFind()
938
+ return true
939
+ },
940
+ }
941
+ },
942
+ })
943
+
843
944
  const isSpacing = (value: number) => Number.isFinite(value) && value >= 0.8 && value <= 3
844
945
 
845
946
  const LineSpacing = Extension.create({
@@ -866,6 +967,26 @@ const LineSpacing = Extension.create({
866
967
  },
867
968
  })
868
969
 
970
+ type Suggestion = {
971
+ kind: 'spelling' | 'repeat' | 'grammar'
972
+ word: string
973
+ from: number
974
+ to: number
975
+ x: number
976
+ y: number
977
+ options: string[]
978
+ message?: string
979
+ }
980
+
981
+ let grammarProbe: Promise<boolean> | null = null
982
+ const grammarReady = (headers: () => HeadersInit) =>
983
+ (grammarProbe ??= fetch('/api/mail/grammar', { headers: headers() })
984
+ .then(response => response.json())
985
+ .then(data => Boolean(data?.available))
986
+ .catch(() => false))
987
+
988
+ const countWords = (text: string) => (text.match(/[A-Za-z\u00C0-\u024F0-9]+(?:['\u2019-][A-Za-z\u00C0-\u024F0-9]+)*/g) ?? []).length
989
+
869
990
  export default function RichEditor({
870
991
  html,
871
992
  onChange,
@@ -874,6 +995,17 @@ export default function RichEditor({
874
995
  fonts,
875
996
  fontFaceCss,
876
997
  baseFont,
998
+ writing,
999
+ onWritingChange,
1000
+ templates = [],
1001
+ onSaveTemplate,
1002
+ onDeleteTemplate,
1003
+ templateContext,
1004
+ companyWords = [],
1005
+ onCompanyWords,
1006
+ isAdmin = false,
1007
+ onSubmit,
1008
+ requestHeaders,
877
1009
  }: {
878
1010
  html: string
879
1011
  onChange: (html: string) => void
@@ -882,12 +1014,51 @@ export default function RichEditor({
882
1014
  fonts?: string[]
883
1015
  fontFaceCss?: string
884
1016
  baseFont?: BaseFont
1017
+ /** Writing tools are on only where this is given: the composer, not the signature editors. */
1018
+ writing?: WritingSettings
1019
+ onWritingChange?: (next: WritingSettings) => void
1020
+ templates?: MailTemplate[]
1021
+ onSaveTemplate?: (template: MailTemplate) => void
1022
+ onDeleteTemplate?: (id: string) => void
1023
+ templateContext?: TemplateContext
1024
+ companyWords?: string[]
1025
+ onCompanyWords?: (words: string[]) => void
1026
+ isAdmin?: boolean
1027
+ onSubmit?: () => void
1028
+ requestHeaders?: () => HeadersInit
885
1029
  }) {
1030
+ const tools = Boolean(writing)
1031
+ const settings = writing ?? WRITING_DEFAULTS
886
1032
  const uploadRef = useRef(uploadImage)
887
1033
  const editorRef = useRef<Editor | null>(null)
1034
+ const settingsRef = useRef(settings)
1035
+ const [spellOn, setSpellOn] = useState(settings.spellcheck)
1036
+ const [grammarAvailable, setGrammarAvailable] = useState(false)
1037
+ const [panel, setPanel] = useState(false)
1038
+ const [finding, setFinding] = useState(false)
1039
+ const [findQuery, setFindQuery] = useState('')
1040
+ const [replaceWith, setReplaceWith] = useState('')
1041
+ const [suggestion, setSuggestion] = useState<Suggestion | null>(null)
1042
+ const [wordCount, setWordCount] = useState(0)
1043
+ const [, setTick] = useState(0)
1044
+ const findInputRef = useRef<HTMLInputElement>(null)
1045
+ const wrapRef = useRef<HTMLDivElement>(null)
1046
+
888
1047
  useEffect(() => {
889
1048
  uploadRef.current = uploadImage
890
- }, [uploadImage])
1049
+ settingsRef.current = settings
1050
+ })
1051
+
1052
+ useEffect(() => {
1053
+ if (!tools) return
1054
+ let current = true
1055
+ void grammarReady(requestHeaders ?? (() => ({}))).then(available => {
1056
+ if (current) setGrammarAvailable(available)
1057
+ })
1058
+ return () => {
1059
+ current = false
1060
+ }
1061
+ }, [tools, requestHeaders])
891
1062
 
892
1063
  // A dropped or pasted image file is uploaded and inserted by URL. Left to the browser,
893
1064
  // Chrome on Windows inserts it as file:///C:/… — which renders for nobody.
@@ -912,6 +1083,47 @@ export default function RichEditor({
912
1083
  return true
913
1084
  }
914
1085
 
1086
+ const openSuggestion = (target: HTMLElement, pos: number) => {
1087
+ const editor = editorRef.current
1088
+ const wrap = wrapRef.current
1089
+ if (!editor || !wrap) return false
1090
+ const kind = target.dataset.kind as Suggestion['kind'] | undefined
1091
+ if (!kind) return false
1092
+ const key = kind === 'grammar' ? grammarKey : spellingKey
1093
+ const set = key.getState(editor.state)?.set
1094
+ const near = set?.find(pos - 1, pos + 1) ?? []
1095
+ const range = near.find(decoration => decoration.from <= pos && decoration.to >= pos) ?? near[0]
1096
+ if (!range) return false
1097
+ const box = target.getBoundingClientRect()
1098
+ const frame = wrap.getBoundingClientRect()
1099
+ const base: Suggestion = {
1100
+ kind,
1101
+ word: editor.state.doc.textBetween(range.from, range.to),
1102
+ from: range.from,
1103
+ to: range.to,
1104
+ x: box.left - frame.left,
1105
+ y: box.bottom - frame.top + 4,
1106
+ options: [],
1107
+ message: target.dataset.message,
1108
+ }
1109
+ if (kind === 'grammar') {
1110
+ try {
1111
+ base.options = JSON.parse(target.dataset.replacements ?? '[]')
1112
+ } catch {
1113
+ base.options = []
1114
+ }
1115
+ setSuggestion(base)
1116
+ } else if (kind === 'repeat') {
1117
+ setSuggestion(base)
1118
+ } else {
1119
+ setSuggestion(base)
1120
+ void suggestSpelling(base.word, settingsRef.current.spellLanguage).then(options =>
1121
+ setSuggestion(current => (current && current.from === base.from && current.word === base.word ? { ...current, options } : current)),
1122
+ )
1123
+ }
1124
+ return true
1125
+ }
1126
+
915
1127
  const editor = useEditor({
916
1128
  // Rendered on the client only: the editor touches the DOM on creation and would
917
1129
  // otherwise mismatch the server-rendered markup.
@@ -925,6 +1137,13 @@ export default function RichEditor({
925
1137
  FontFamily,
926
1138
  FontSize,
927
1139
  LineSpacing,
1140
+ LiveStore,
1141
+ TabKey,
1142
+ WritingAssist,
1143
+ Spelling,
1144
+ Grammar,
1145
+ FindReplace,
1146
+ ComposerShortcuts,
928
1147
  Color,
929
1148
  Highlight.configure({ multicolor: true }),
930
1149
  Link.configure({ openOnClick: false, autolink: true }),
@@ -937,12 +1156,36 @@ export default function RichEditor({
937
1156
  ],
938
1157
  content: html || '',
939
1158
  editorProps: {
940
- attributes: { class: styles.rteSurface, 'aria-label': placeholder ?? 'Message body' },
1159
+ attributes: {
1160
+ class: styles.rteSurface,
1161
+ 'aria-label': placeholder ?? 'Message body',
1162
+ spellcheck: 'false',
1163
+ autocapitalize: tools ? 'sentences' : 'off',
1164
+ autocorrect: tools ? 'on' : 'off',
1165
+ },
941
1166
  handleDrop: (view, event) =>
942
1167
  placeImages(Array.from(event.dataTransfer?.files ?? []), view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos),
1168
+ handleKeyDown: (_view, event) => {
1169
+ if (event.key === 'Escape') setSuggestion(null)
1170
+ return false
1171
+ },
943
1172
  handlePaste: (_view, event) => placeImages(Array.from(event.clipboardData?.files ?? [])),
1173
+ transformPastedHTML: pasted => (settingsRef.current.cleanPaste ? cleanPastedHtml(pasted) : pasted),
1174
+ handleClick: (_view, pos, event) => {
1175
+ const target = (event.target as HTMLElement | null)?.closest('[data-kind]') as HTMLElement | null
1176
+ if (!target) {
1177
+ setSuggestion(null)
1178
+ return false
1179
+ }
1180
+ return openSuggestion(target, pos)
1181
+ },
1182
+ },
1183
+ onUpdate: ({ editor: instance }) => {
1184
+ onChange(instance.getHTML())
1185
+ setWordCount(countWords(instance.state.doc.textContent))
944
1186
  },
945
- onUpdate: ({ editor: instance }) => onChange(instance.getHTML()),
1187
+ onCreate: ({ editor: instance }) => setWordCount(countWords(instance.state.doc.textContent)),
1188
+ onTransaction: () => setTick(tick => tick + 1),
946
1189
  })
947
1190
 
948
1191
  // Only push content in when it changed elsewhere — writing on every keystroke would
@@ -958,8 +1201,101 @@ export default function RichEditor({
958
1201
  editorRef.current = editor
959
1202
  }, [editor])
960
1203
 
1204
+ useEffect(() => {
1205
+ if (!editor || editor.isDestroyed) return
1206
+ Object.assign(live(editor), {
1207
+ tools,
1208
+ settings,
1209
+ templates,
1210
+ context: templateContext ?? {},
1211
+ words: [...settings.personalWords, ...companyWords],
1212
+ spellOn: tools && spellOn,
1213
+ grammarOn: tools && settings.grammar && grammarAvailable,
1214
+ headers: requestHeaders ?? (() => ({})),
1215
+ submit: onSubmit,
1216
+ openFind: tools
1217
+ ? () => {
1218
+ setFinding(true)
1219
+ window.setTimeout(() => findInputRef.current?.select(), 0)
1220
+ }
1221
+ : undefined,
1222
+ })
1223
+ })
1224
+
1225
+ const personalKey = settings.personalWords.join('|')
1226
+ const companyKey = companyWords.join('|')
1227
+ useEffect(() => {
1228
+ if (!editor || !tools || editor.isDestroyed) return
1229
+ editor.view.dispatch(editor.state.tr.setMeta(spellingKey, 'refresh').setMeta('addToHistory', false))
1230
+ }, [editor, tools, spellOn, settings.spellLanguage, settings.ignoreCapitals, settings.ignoreWithNumbers, settings.repeatedWords, personalKey, companyKey])
1231
+
1232
+ useEffect(() => {
1233
+ if (!editor || !tools || editor.isDestroyed) return
1234
+ editor.view.dispatch(editor.state.tr.setMeta(grammarKey, 'refresh').setMeta('addToHistory', false))
1235
+ }, [editor, tools, settings.grammar, grammarAvailable, settings.spellLanguage])
1236
+
1237
+ useEffect(() => {
1238
+ if (!editor || !tools || editor.isDestroyed) return
1239
+ editor.view.dispatch(editor.state.tr.setMeta(findKey, { query: finding ? findQuery : '', index: 0 }).setMeta('addToHistory', false))
1240
+ }, [editor, tools, finding, findQuery])
1241
+
961
1242
  if (!editor) return <div className={styles.rteLoading} />
962
1243
 
1244
+ const matches = tools && finding ? findMatches(editor.state.doc, findQuery) : []
1245
+ const findIndex = matches.length ? ((findState(editor.state).index % matches.length) + matches.length) % matches.length : 0
1246
+ const moveFind = (step: number) => {
1247
+ if (!matches.length) return
1248
+ const next = (findIndex + step + matches.length) % matches.length
1249
+ editor.view.dispatch(editor.state.tr.setMeta(findKey, { query: findQuery, index: next }))
1250
+ editor.commands.setTextSelection(matches[next])
1251
+ editor.commands.scrollIntoView()
1252
+ }
1253
+ const replaceCurrent = () => {
1254
+ const match = matches[findIndex]
1255
+ if (!match) return
1256
+ editor.chain().insertContentAt(match, replaceWith).run()
1257
+ }
1258
+ const replaceAll = () => {
1259
+ if (!matches.length) return
1260
+ const tr = editor.state.tr
1261
+ for (const match of [...matches].reverse()) tr.insertText(replaceWith, match.from, match.to)
1262
+ editor.view.dispatch(tr)
1263
+ }
1264
+
1265
+ const applySuggestion = (text: string) => {
1266
+ if (!suggestion) return
1267
+ editor.chain().focus().insertContentAt({ from: suggestion.from, to: suggestion.to }, text).run()
1268
+ setSuggestion(null)
1269
+ }
1270
+ const ignoreSuggestion = () => {
1271
+ if (!suggestion) return
1272
+ if (suggestion.kind === 'grammar') {
1273
+ const grammar = (editor.storage as unknown as Record<string, { ignored?: Set<string> }>).grammar
1274
+ grammar?.ignored?.add(grammarIgnoreKey(suggestion.word, suggestion.message ?? ''))
1275
+ const current = grammarKey.getState(editor.state)?.set
1276
+ if (current) editor.view.dispatch(editor.state.tr.setMeta(grammarKey, current.remove(current.find(suggestion.from, suggestion.to))).setMeta('addToHistory', false))
1277
+ setSuggestion(null)
1278
+ return
1279
+ }
1280
+ const spelling = (editor.storage as unknown as Record<string, { ignored?: Set<string> }>).spelling
1281
+ spelling?.ignored?.add(suggestion.word.toLowerCase())
1282
+ editor.view.dispatch(editor.state.tr.setMeta(spellingKey, 'refresh').setMeta('addToHistory', false))
1283
+ setSuggestion(null)
1284
+ }
1285
+ const removeRepeat = () => {
1286
+ if (!suggestion) return
1287
+ const before = editor.state.doc.textBetween(Math.max(0, suggestion.from - 1), suggestion.from)
1288
+ editor.chain().focus().deleteRange({ from: /\s/.test(before) ? suggestion.from - 1 : suggestion.from, to: suggestion.to }).run()
1289
+ setSuggestion(null)
1290
+ }
1291
+ const insertTemplate = (template: MailTemplate) => {
1292
+ const at = editor.state.selection.from
1293
+ editor.chain().focus().insertContent(fillTemplate(template.html, templateContext ?? {})).run()
1294
+ const field = nextField(editor.state.doc, at)
1295
+ if (field) editor.commands.setTextSelection(field)
1296
+ setPanel(false)
1297
+ }
1298
+
963
1299
  return (
964
1300
  <div
965
1301
  className={styles.rte}
@@ -967,7 +1303,116 @@ export default function RichEditor({
967
1303
  >
968
1304
  {fontFaceCss && <style>{fontFaceCss}</style>}
969
1305
  <Toolbar editor={editor} uploadImage={uploadImage} fonts={fonts} baseFont={baseFont} />
970
- <EditorContent editor={editor} className={styles.rteContent} />
1306
+ {tools && finding && (
1307
+ <div className={styles.rteFindBar} role="search">
1308
+ <input
1309
+ ref={findInputRef}
1310
+ className={styles.rteFindInput}
1311
+ placeholder="Find"
1312
+ value={findQuery}
1313
+ onChange={event => setFindQuery(event.target.value)}
1314
+ onKeyDown={event => {
1315
+ if (event.key === 'Enter') {
1316
+ event.preventDefault()
1317
+ moveFind(event.shiftKey ? -1 : 1)
1318
+ }
1319
+ if (event.key === 'Escape') setFinding(false)
1320
+ }}
1321
+ />
1322
+ <span className={styles.rteFindCount}>{findQuery ? (matches.length ? `${findIndex + 1} of ${matches.length}` : 'No matches') : ''}</span>
1323
+ <button type="button" className={styles.rteFindBtn} onClick={() => moveFind(-1)} aria-label="Previous match">↑</button>
1324
+ <button type="button" className={styles.rteFindBtn} onClick={() => moveFind(1)} aria-label="Next match">↓</button>
1325
+ <input className={styles.rteFindInput} placeholder="Replace with" value={replaceWith} onChange={event => setReplaceWith(event.target.value)} />
1326
+ <button type="button" className={styles.rteFindBtn} onClick={replaceCurrent} disabled={!matches.length}>Replace</button>
1327
+ <button type="button" className={styles.rteFindBtn} onClick={replaceAll} disabled={!matches.length}>All</button>
1328
+ <button type="button" className={styles.rteFindBtn} onClick={() => setFinding(false)} aria-label="Close find">×</button>
1329
+ </div>
1330
+ )}
1331
+ <div ref={wrapRef} className={styles.rteBody}>
1332
+ {tools && (
1333
+ <div className={styles.rteCorner}>
1334
+ <button
1335
+ type="button"
1336
+ className={`${styles.rteCornerBtn} ${spellOn ? styles.rteCornerOn : ''}`}
1337
+ aria-pressed={spellOn}
1338
+ title={spellOn ? 'Spelling check is on for this message — click to turn it off' : 'Spelling check is off for this message — click to turn it on'}
1339
+ onClick={() => setSpellOn(on => !on)}
1340
+ >
1341
+ <span aria-hidden>✓</span>Aa
1342
+ </button>
1343
+ <button
1344
+ type="button"
1345
+ className={`${styles.rteCornerBtn} ${panel ? styles.rteCornerOn : ''}`}
1346
+ aria-expanded={panel}
1347
+ title="Writing tools"
1348
+ onClick={() => setPanel(open => !open)}
1349
+ >
1350
+ <span aria-hidden className={styles.rteGear}>⚙</span>
1351
+ </button>
1352
+ </div>
1353
+ )}
1354
+ <EditorContent editor={editor} className={styles.rteContent} />
1355
+ {tools && settings.wordCount && <div className={styles.rteWordCount}>{wordCount} {wordCount === 1 ? 'word' : 'words'}</div>}
1356
+ {tools && panel && writing && onWritingChange && (
1357
+ <WritingPanel
1358
+ settings={writing}
1359
+ onChange={onWritingChange}
1360
+ templates={templates}
1361
+ onSaveTemplate={onSaveTemplate}
1362
+ onDeleteTemplate={onDeleteTemplate}
1363
+ onInsertTemplate={insertTemplate}
1364
+ currentHtml={editor.getHTML()}
1365
+ companyWords={companyWords}
1366
+ onCompanyWords={onCompanyWords}
1367
+ isAdmin={isAdmin}
1368
+ grammarAvailable={grammarAvailable}
1369
+ onClose={() => setPanel(false)}
1370
+ />
1371
+ )}
1372
+ {suggestion && (
1373
+ <div className={styles.rteSuggest} style={{ left: suggestion.x, top: suggestion.y }} role="menu">
1374
+ {suggestion.kind === 'grammar' && suggestion.message && <p className={styles.rteSuggestNote}>{suggestion.message}</p>}
1375
+ {suggestion.kind === 'repeat' ? (
1376
+ <button type="button" className={styles.rteSuggestPick} onClick={removeRepeat}>Remove the repeated “{suggestion.word}”</button>
1377
+ ) : (
1378
+ <>
1379
+ {suggestion.options.length > 0 ? (
1380
+ suggestion.options.map(option => (
1381
+ <button key={option} type="button" className={styles.rteSuggestPick} onClick={() => applySuggestion(option)}>{option}</button>
1382
+ ))
1383
+ ) : (
1384
+ <p className={styles.rteSuggestNote}>{suggestion.kind === 'spelling' ? 'Looking for suggestions…' : 'No suggestion'}</p>
1385
+ )}
1386
+ <div className={styles.rteSuggestActions}>
1387
+ <button type="button" onClick={ignoreSuggestion}>Ignore</button>
1388
+ {suggestion.kind === 'spelling' && onWritingChange && writing && (
1389
+ <button
1390
+ type="button"
1391
+ onClick={() => {
1392
+ onWritingChange({ ...writing, personalWords: [...new Set([...writing.personalWords, suggestion.word])] })
1393
+ setSuggestion(null)
1394
+ }}
1395
+ >
1396
+ Add to dictionary
1397
+ </button>
1398
+ )}
1399
+ {suggestion.kind === 'spelling' && isAdmin && onCompanyWords && (
1400
+ <button
1401
+ type="button"
1402
+ onClick={() => {
1403
+ onCompanyWords([...new Set([...companyWords, suggestion.word])])
1404
+ setSuggestion(null)
1405
+ }}
1406
+ >
1407
+ Add for the company
1408
+ </button>
1409
+ )}
1410
+ </div>
1411
+ </>
1412
+ )}
1413
+ </div>
1414
+ )}
1415
+ </div>
971
1416
  </div>
972
1417
  )
973
1418
  }