@novacraft-engineering/mailbox 0.4.25 → 0.4.27

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.
@@ -0,0 +1,36 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard, resolveAccount } from '@/lib/dev-auth'
3
+ import { getSettings, setSettings } from '@/lib/mailbox'
4
+
5
+ export const runtime = 'nodejs'
6
+
7
+ const COMPANY = '__company__'
8
+
9
+ const wordsFrom = (value: unknown): string[] =>
10
+ Array.isArray(value)
11
+ ? [...new Set(value.filter((word): word is string => typeof word === 'string').map(word => word.trim()).filter(word => word && word.length <= 60))].slice(0, 5000)
12
+ : []
13
+
14
+ /** Words everyone's spelling check accepts: client, insurer and product names. */
15
+ export async function GET(req: Request) {
16
+ const guard = await mailAuthGuard(req)
17
+ if (guard) return guard
18
+ const stored = await getSettings(COMPANY)
19
+ return NextResponse.json({ ok: true, words: wordsFrom(stored.dictionary) })
20
+ }
21
+
22
+ export async function PUT(req: Request) {
23
+ const guard = await mailAuthGuard(req)
24
+ if (guard) return guard
25
+ const account = await resolveAccount(req)
26
+ if (account.role !== 'admin') return NextResponse.json({ ok: false, error: 'Admin access required' }, { status: 403 })
27
+ let body: { words?: unknown }
28
+ try {
29
+ body = await req.json()
30
+ } catch {
31
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
32
+ }
33
+ const words = wordsFrom(body.words)
34
+ await setSettings(COMPANY, { ...(await getSettings(COMPANY)), dictionary: words })
35
+ return NextResponse.json({ ok: true, words })
36
+ }
@@ -31,6 +31,6 @@ export async function PUT(req: Request) {
31
31
  }
32
32
  const signature = typeof body.signature === 'string' ? body.signature : ''
33
33
  const logo = typeof body.logo === 'string' ? body.logo : ''
34
- await setSettings(COMPANY, { signature, logo })
34
+ await setSettings(COMPANY, { ...(await getSettings(COMPANY)), signature, logo })
35
35
  return NextResponse.json({ ok: true })
36
36
  }
@@ -0,0 +1,52 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { mailAuthGuard } from '@/lib/dev-auth'
3
+
4
+ export const runtime = 'nodejs'
5
+
6
+ const server = () => process.env.LANGUAGETOOL_URL?.trim().replace(/\/+$/, '') || ''
7
+
8
+ // Spelling is checked in the browser against the company's own dictionaries; LanguageTool's
9
+ // spell rule would flag every client and insurer name a second time. The whitespace rule
10
+ // fires on the no-break spaces the Tab key inserts.
11
+ const SKIPPED_RULES = /^(MORFOLOGIK_RULE|HUNSPELL_RULE|WHITESPACE_RULE|CONSECUTIVE_SPACES|EN_QUOTES|DASH_RULE|UPPERCASE_SENTENCE_START|ENGLISH_WORD_REPEAT_RULE)/
12
+
13
+ export async function GET(req: Request) {
14
+ const guard = await mailAuthGuard(req)
15
+ if (guard) return guard
16
+ return NextResponse.json({ ok: true, available: Boolean(server()) })
17
+ }
18
+
19
+ export async function POST(req: Request) {
20
+ const guard = await mailAuthGuard(req)
21
+ if (guard) return guard
22
+ const base = server()
23
+ if (!base) return NextResponse.json({ ok: false, error: 'Grammar checking is not set up' }, { status: 503 })
24
+ let body: { text?: unknown; language?: unknown }
25
+ try {
26
+ body = await req.json()
27
+ } catch {
28
+ return NextResponse.json({ ok: false, error: 'Invalid JSON' }, { status: 400 })
29
+ }
30
+ const text = typeof body.text === 'string' ? body.text.slice(0, 20000) : ''
31
+ const language = body.language === 'en-US' ? 'en-US' : 'en-GB'
32
+ if (!text.trim()) return NextResponse.json({ ok: true, matches: [] })
33
+ const upstream = await fetch(`${base}/v2/check`, {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
36
+ body: new URLSearchParams({ text, language, level: 'default' }),
37
+ signal: AbortSignal.timeout(15000),
38
+ }).catch(() => null)
39
+ if (!upstream?.ok) return NextResponse.json({ ok: false, error: 'The grammar server did not answer' }, { status: 502 })
40
+ const data = (await upstream.json()) as {
41
+ matches?: Array<{ offset: number; length: number; message: string; replacements?: Array<{ value: string }>; rule?: { id?: string } }>
42
+ }
43
+ const matches = (data.matches ?? [])
44
+ .filter(match => !SKIPPED_RULES.test(match.rule?.id ?? ''))
45
+ .map(match => ({
46
+ offset: match.offset,
47
+ length: match.length,
48
+ message: match.message,
49
+ replacements: (match.replacements ?? []).slice(0, 5).map(entry => entry.value),
50
+ }))
51
+ return NextResponse.json({ ok: true, matches })
52
+ }
@@ -203,6 +203,9 @@ export async function POST(req: Request) {
203
203
  text: body.text,
204
204
  attachments,
205
205
  ...(inReplyToId ? { headers: { 'In-Reply-To': inReplyToId, References: inReplyToId } } : {}),
206
+ // A person's own message is already in their Sent folder; the archive is for mail the
207
+ // app sends on its own behalf, which no one would otherwise see.
208
+ skipArchive: true,
206
209
  })
207
210
  } catch (sendError) {
208
211
  return NextResponse.json({ ok: false, error: (sendError as Error).message }, { status: 502 })
@@ -18,6 +18,15 @@ import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table
18
18
  import { safeHref } from '@/lib/email-html'
19
19
  import styles from './page.module.css'
20
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'
21
30
 
22
31
  const TEXT_COLOURS = ['#030712', '#b91c1c', '#1d4ed8', '#047857', '#b45309', '#6d28d9', '#6b7280']
23
32
  const HIGHLIGHTS = ['#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#FED7AA']
@@ -591,7 +600,17 @@ function TableTools({ editor }: { editor: Editor }) {
591
600
 
592
601
  type Pop = 'link' | 'image' | 'table' | null
593
602
 
594
- 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
+ }) {
595
614
  const [pop, setPop] = useState<Pop>(null)
596
615
  const popRef = useRef<HTMLDivElement>(null)
597
616
  const phone = usePhone()
@@ -626,6 +645,10 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
626
645
  setPop(current => (current === 'link' ? null : 'link'))
627
646
  }, [editor])
628
647
 
648
+ useEffect(() => {
649
+ live(editor).openLink = openLink
650
+ }, [editor, openLink])
651
+
629
652
  return (
630
653
  <>
631
654
  <div className={styles.rteToolbar} role="toolbar" aria-label="Formatting">
@@ -859,7 +882,14 @@ const TabKey = Extension.create<Record<string, never>, { memory: { at: number; m
859
882
  Tab: () => {
860
883
  const editor = this.editor
861
884
  if (editor.isActive('table')) return false
862
- const { leaving, previous } = tabPress(this.storage.memory, () => null)
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)
863
893
  if (leaving) {
864
894
  if (previous && previous.doc === editor.state.doc) previous.undo()
865
895
  return false
@@ -885,6 +915,32 @@ const TabKey = Extension.create<Record<string, never>, { memory: { at: number; m
885
915
  },
886
916
  })
887
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
+
888
944
  const isSpacing = (value: number) => Number.isFinite(value) && value >= 0.8 && value <= 3
889
945
 
890
946
  const LineSpacing = Extension.create({
@@ -911,6 +967,26 @@ const LineSpacing = Extension.create({
911
967
  },
912
968
  })
913
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
+
914
990
  export default function RichEditor({
915
991
  html,
916
992
  onChange,
@@ -919,6 +995,17 @@ export default function RichEditor({
919
995
  fonts,
920
996
  fontFaceCss,
921
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,
922
1009
  }: {
923
1010
  html: string
924
1011
  onChange: (html: string) => void
@@ -927,12 +1014,51 @@ export default function RichEditor({
927
1014
  fonts?: string[]
928
1015
  fontFaceCss?: string
929
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
930
1029
  }) {
1030
+ const tools = Boolean(writing)
1031
+ const settings = writing ?? WRITING_DEFAULTS
931
1032
  const uploadRef = useRef(uploadImage)
932
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
+
933
1047
  useEffect(() => {
934
1048
  uploadRef.current = uploadImage
935
- }, [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])
936
1062
 
937
1063
  // A dropped or pasted image file is uploaded and inserted by URL. Left to the browser,
938
1064
  // Chrome on Windows inserts it as file:///C:/… — which renders for nobody.
@@ -957,6 +1083,47 @@ export default function RichEditor({
957
1083
  return true
958
1084
  }
959
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
+
960
1127
  const editor = useEditor({
961
1128
  // Rendered on the client only: the editor touches the DOM on creation and would
962
1129
  // otherwise mismatch the server-rendered markup.
@@ -970,7 +1137,13 @@ export default function RichEditor({
970
1137
  FontFamily,
971
1138
  FontSize,
972
1139
  LineSpacing,
1140
+ LiveStore,
973
1141
  TabKey,
1142
+ WritingAssist,
1143
+ Spelling,
1144
+ Grammar,
1145
+ FindReplace,
1146
+ ComposerShortcuts,
974
1147
  Color,
975
1148
  Highlight.configure({ multicolor: true }),
976
1149
  Link.configure({ openOnClick: false, autolink: true }),
@@ -983,12 +1156,36 @@ export default function RichEditor({
983
1156
  ],
984
1157
  content: html || '',
985
1158
  editorProps: {
986
- 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
+ },
987
1166
  handleDrop: (view, event) =>
988
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
+ },
989
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))
990
1186
  },
991
- onUpdate: ({ editor: instance }) => onChange(instance.getHTML()),
1187
+ onCreate: ({ editor: instance }) => setWordCount(countWords(instance.state.doc.textContent)),
1188
+ onTransaction: () => setTick(tick => tick + 1),
992
1189
  })
993
1190
 
994
1191
  // Only push content in when it changed elsewhere — writing on every keystroke would
@@ -1004,8 +1201,101 @@ export default function RichEditor({
1004
1201
  editorRef.current = editor
1005
1202
  }, [editor])
1006
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
+
1007
1242
  if (!editor) return <div className={styles.rteLoading} />
1008
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
+
1009
1299
  return (
1010
1300
  <div
1011
1301
  className={styles.rte}
@@ -1013,7 +1303,116 @@ export default function RichEditor({
1013
1303
  >
1014
1304
  {fontFaceCss && <style>{fontFaceCss}</style>}
1015
1305
  <Toolbar editor={editor} uploadImage={uploadImage} fonts={fonts} baseFont={baseFont} />
1016
- <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>
1017
1416
  </div>
1018
1417
  )
1019
1418
  }