@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.
- package/app/api/mail/company-dictionary/route.ts +36 -0
- package/app/api/mail/company-signature/route.ts +1 -1
- package/app/api/mail/grammar/route.ts +52 -0
- package/app/api/mail/send/route.ts +3 -0
- package/app/mail/RichEditor.tsx +405 -6
- package/app/mail/page.module.css +304 -9
- package/app/mail/page.tsx +121 -8
- package/app/mail/scale.test.ts +17 -0
- package/app/mail/scale.ts +15 -0
- package/app/mail/tabKey.ts +6 -2
- 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/mail-provider.ts +1 -1
- package/lib/scheduled.ts +4 -3
- 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
|
@@ -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 })
|
package/app/mail/RichEditor.tsx
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|