@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.
- package/.env.example +8 -2
- package/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/events/ses/route.ts +88 -0
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/api/mail/scheduled/route.ts +15 -1
- package/app/mail/RichEditor.tsx +450 -5
- package/app/mail/page.module.css +275 -0
- package/app/mail/page.tsx +115 -12
- package/app/mail/tabKey.ts +52 -0
- package/app/mail/writing/WritingPanel.tsx +259 -0
- package/app/mail/writing/assist.ts +139 -0
- package/app/mail/writing/checks.test.ts +48 -0
- package/app/mail/writing/checks.ts +58 -0
- package/app/mail/writing/find.ts +52 -0
- package/app/mail/writing/grammar.ts +100 -0
- package/app/mail/writing/live.ts +34 -0
- package/app/mail/writing/paste.test.ts +17 -0
- package/app/mail/writing/paste.ts +30 -0
- package/app/mail/writing/settings.ts +98 -0
- package/app/mail/writing/spell.worker.ts +40 -0
- package/app/mail/writing/spelling.ts +164 -0
- package/app/mail/writing/templates.ts +40 -0
- package/app/mail/writing/text.test.ts +88 -0
- package/app/mail/writing/text.ts +106 -0
- package/lib/dev-auth.ts +5 -3
- package/lib/dmarc-report.test.ts +18 -0
- package/lib/mail-provider.ts +1 -2
- package/lib/mailbox.ts +7 -2
- package/lib/receive.test.ts +24 -0
- package/lib/receive.ts +55 -11
- package/lib/scheduled.ts +67 -33
- package/lib/ses-send.test.ts +21 -0
- package/lib/ses-send.ts +29 -5
- package/lib/sns.ts +68 -0
- package/package.json +3 -1
- package/public/dictionaries/en-GB.LICENSE +347 -0
- package/public/dictionaries/en-GB.aff +205 -0
- package/public/dictionaries/en-GB.dic +49602 -0
- package/public/dictionaries/en-US.LICENSE +347 -0
- package/public/dictionaries/en-US.aff +205 -0
- package/public/dictionaries/en-US.dic +49569 -0
package/app/mail/RichEditor.tsx
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|