@remit/web-client 0.0.133 → 0.0.134
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/package.json +1 -5
- package/src/components/compose/ComposeBody.tsx +12 -26
- package/src/components/compose/ComposeForm.tsx +72 -36
- package/src/components/compose/MobileComposeSheet.tsx +1 -1
- package/src/components/compose/PlateEditor.tsx +0 -86
- package/src/components/compose/PlateToolbar.tsx +0 -110
- package/src/components/compose/sanitize-quote-html.ts +0 -47
- package/src/lib/plate-serializer.ts +0 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.134",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -61,9 +61,6 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@hookform/resolvers": "*",
|
|
64
|
-
"@platejs/autoformat": "*",
|
|
65
|
-
"@platejs/basic-nodes": "*",
|
|
66
|
-
"@platejs/link": "*",
|
|
67
64
|
"@remit/api-http-client": "*",
|
|
68
65
|
"@remit/domain-enums": "*",
|
|
69
66
|
"@remit/ui": "*",
|
|
@@ -82,7 +79,6 @@
|
|
|
82
79
|
"i18next-http-backend": "^3.0.6",
|
|
83
80
|
"lucide-react": "^0.468",
|
|
84
81
|
"p-map": "*",
|
|
85
|
-
"platejs": "*",
|
|
86
82
|
"react-hook-form": "*",
|
|
87
83
|
"react-i18next": "^15",
|
|
88
84
|
"tailwind-merge": "^2",
|
|
@@ -1,36 +1,22 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { Plate } from "platejs/react";
|
|
3
|
-
import { PlateEditorContent, usePlateComposeEditor } from "./PlateEditor.js";
|
|
4
|
-
import { PlateToolbar } from "./PlateToolbar.js";
|
|
1
|
+
import { RichTextEditor, type RichTextValue } from "@remit/ui/rich-text";
|
|
5
2
|
|
|
6
3
|
interface ComposeBodyProps {
|
|
7
|
-
|
|
8
|
-
onChange: (value:
|
|
4
|
+
initialHtml: string;
|
|
5
|
+
onChange: (value: RichTextValue) => void;
|
|
9
6
|
onSubmit?: () => void;
|
|
10
7
|
autoFocus?: boolean;
|
|
11
8
|
}
|
|
12
9
|
|
|
13
10
|
export const ComposeBody = ({
|
|
14
|
-
|
|
11
|
+
initialHtml,
|
|
15
12
|
onChange,
|
|
16
13
|
onSubmit,
|
|
17
14
|
autoFocus,
|
|
18
|
-
}: ComposeBodyProps) =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}}
|
|
27
|
-
>
|
|
28
|
-
<PlateToolbar />
|
|
29
|
-
<PlateEditorContent
|
|
30
|
-
editor={editor}
|
|
31
|
-
onSubmit={onSubmit}
|
|
32
|
-
autoFocus={autoFocus}
|
|
33
|
-
/>
|
|
34
|
-
</Plate>
|
|
35
|
-
);
|
|
36
|
-
};
|
|
15
|
+
}: ComposeBodyProps) => (
|
|
16
|
+
<RichTextEditor
|
|
17
|
+
initialHtml={initialHtml}
|
|
18
|
+
onChange={onChange}
|
|
19
|
+
onSubmit={onSubmit}
|
|
20
|
+
autoFocus={autoFocus}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
@@ -9,9 +9,15 @@ import type {
|
|
|
9
9
|
RemitImapAccountResponse,
|
|
10
10
|
RemitImapDescribeMessageResponse,
|
|
11
11
|
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ComposeActionBar,
|
|
14
|
+
ComposeFormShell,
|
|
15
|
+
EMPTY_RICH_TEXT,
|
|
16
|
+
QuotedText,
|
|
17
|
+
type RichTextValue,
|
|
18
|
+
sanitizeQuotedHtml,
|
|
19
|
+
} from "@remit/ui";
|
|
13
20
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
14
|
-
import type { Value } from "platejs";
|
|
15
21
|
import {
|
|
16
22
|
lazy,
|
|
17
23
|
Suspense,
|
|
@@ -23,10 +29,6 @@ import {
|
|
|
23
29
|
import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
|
|
24
30
|
import { useSaveDraft } from "../../hooks/useSaveDraft";
|
|
25
31
|
import { useSignature } from "../../hooks/useSignature.js";
|
|
26
|
-
import {
|
|
27
|
-
plateValueToHtml,
|
|
28
|
-
plateValueToText,
|
|
29
|
-
} from "../../lib/plate-serializer.js";
|
|
30
32
|
import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
|
|
31
33
|
import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
|
|
32
34
|
import {
|
|
@@ -54,7 +56,6 @@ import type { ComposeMode } from "./ComposeProvider";
|
|
|
54
56
|
import { useCompose } from "./ComposeProvider";
|
|
55
57
|
import { FromSelector } from "./FromSelector";
|
|
56
58
|
import { SubjectField } from "./SubjectField";
|
|
57
|
-
import { sanitizeQuoteHtml } from "./sanitize-quote-html.js";
|
|
58
59
|
|
|
59
60
|
interface ComposeFormProps {
|
|
60
61
|
mode: ComposeMode;
|
|
@@ -64,19 +65,22 @@ interface ComposeFormProps {
|
|
|
64
65
|
onAccountChange?: (account: RemitImapAccountResponse) => void;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
const escapeHtml = (text: string): string =>
|
|
69
|
+
text
|
|
70
|
+
.replace(/&/g, "&")
|
|
71
|
+
.replace(/</g, "<")
|
|
72
|
+
.replace(/>/g, ">")
|
|
73
|
+
.replace(/"/g, """);
|
|
74
|
+
|
|
75
|
+
const textToHtml = (text: string): string =>
|
|
76
|
+
text
|
|
77
|
+
.split("\n")
|
|
78
|
+
.map((line) => `<p>${escapeHtml(line)}</p>`)
|
|
79
|
+
.join("");
|
|
80
|
+
|
|
81
|
+
const buildInitialHtml = (signaturePlainText: string): string => {
|
|
82
|
+
if (!signaturePlainText) return "";
|
|
83
|
+
return `<p></p><p>-- </p>${textToHtml(signaturePlainText)}`;
|
|
80
84
|
};
|
|
81
85
|
|
|
82
86
|
const buildReplySubject = (subject?: string): string => {
|
|
@@ -149,13 +153,13 @@ const isFormEmpty = (
|
|
|
149
153
|
ccAddresses: AddressEntry[],
|
|
150
154
|
bccAddresses: AddressEntry[],
|
|
151
155
|
subject: string,
|
|
152
|
-
body:
|
|
156
|
+
body: RichTextValue,
|
|
153
157
|
): boolean =>
|
|
154
158
|
toAddresses.length === 0 &&
|
|
155
159
|
ccAddresses.length === 0 &&
|
|
156
160
|
bccAddresses.length === 0 &&
|
|
157
161
|
subject.trim() === "" &&
|
|
158
|
-
|
|
162
|
+
body.text.trim() === "";
|
|
159
163
|
|
|
160
164
|
// ---------------------------------------------------------------------------
|
|
161
165
|
// ComposeHeader — collapsed on mobile when the software keyboard is open
|
|
@@ -310,24 +314,41 @@ export const ComposeForm = ({
|
|
|
310
314
|
// changes while compose is already open). Without this, the previous draft's
|
|
311
315
|
// fields stay visible and the new draft never loads because draftLoaded
|
|
312
316
|
// remains true from the prior session (#536).
|
|
317
|
+
//
|
|
318
|
+
// A first autosave also sets the id, from nothing to the draft it just
|
|
319
|
+
// created. That is this session's own content arriving back, not a switch to
|
|
320
|
+
// somebody else's document, and blanking the form there would throw away
|
|
321
|
+
// whatever is being typed.
|
|
313
322
|
useEffect(() => {
|
|
314
|
-
|
|
323
|
+
const previous = prevOutboxMessageIdRef.current;
|
|
324
|
+
if (previous === outboxMessageId) return;
|
|
315
325
|
prevOutboxMessageIdRef.current = outboxMessageId;
|
|
316
|
-
if (!outboxMessageId) return;
|
|
326
|
+
if (!outboxMessageId || previous === undefined) return;
|
|
317
327
|
setToAddresses([]);
|
|
318
328
|
setCcAddresses([]);
|
|
319
329
|
setBccAddresses([]);
|
|
320
330
|
setSubject("");
|
|
321
331
|
setShowCc(false);
|
|
322
332
|
setShowBcc(false);
|
|
323
|
-
|
|
333
|
+
setInitialHtml("");
|
|
334
|
+
setBody(EMPTY_RICH_TEXT);
|
|
335
|
+
setDocumentGeneration((generation) => generation + 1);
|
|
324
336
|
setDraftLoaded(false);
|
|
325
337
|
}, [outboxMessageId]);
|
|
326
338
|
|
|
327
339
|
const { signature } = useSignature(selectedAccountId);
|
|
328
|
-
|
|
329
|
-
|
|
340
|
+
// The editor reads its document once, so this is the document it opens on,
|
|
341
|
+
// not the live value, and it is remounted when the generation changes. Only
|
|
342
|
+
// loading a different document bumps that — remounting mid-compose would take
|
|
343
|
+
// the caret, the focus and the undo history with it.
|
|
344
|
+
const [documentGeneration, setDocumentGeneration] = useState(0);
|
|
345
|
+
const [initialHtml, setInitialHtml] = useState(() =>
|
|
346
|
+
buildInitialHtml(signature.plainText),
|
|
330
347
|
);
|
|
348
|
+
const [body, setBody] = useState<RichTextValue>(() => ({
|
|
349
|
+
html: buildInitialHtml(signature.plainText),
|
|
350
|
+
text: signature.plainText,
|
|
351
|
+
}));
|
|
331
352
|
|
|
332
353
|
const { data: draftData } = useQuery({
|
|
333
354
|
...outboxDetailOperationsGetOutboxMessageOptions({
|
|
@@ -361,8 +382,13 @@ export const ComposeForm = ({
|
|
|
361
382
|
setShowBcc(true);
|
|
362
383
|
}
|
|
363
384
|
if (draftData.subject) setSubject(draftData.subject);
|
|
364
|
-
|
|
365
|
-
|
|
385
|
+
// A draft stores what would have been sent, so a rich one reopens from its
|
|
386
|
+
// HTML. Only a draft that never had any falls back to its text.
|
|
387
|
+
const loadedHtml =
|
|
388
|
+
draftData.htmlBody || textToHtml(draftData.textBody ?? "");
|
|
389
|
+
setInitialHtml(loadedHtml);
|
|
390
|
+
setBody({ html: loadedHtml, text: draftData.textBody ?? "" });
|
|
391
|
+
setDocumentGeneration((generation) => generation + 1);
|
|
366
392
|
setSelectedAccountId(draftData.accountId);
|
|
367
393
|
setDraftLoaded(true);
|
|
368
394
|
}, [draftData, draftLoaded]);
|
|
@@ -398,16 +424,27 @@ export const ComposeForm = ({
|
|
|
398
424
|
const quotedText = sourceBody?.kind === "text" ? sourceBody.body : "";
|
|
399
425
|
const quotedHtml =
|
|
400
426
|
sourceBody?.kind === "html"
|
|
401
|
-
?
|
|
427
|
+
? sanitizeQuotedHtml(sourceBody.body)
|
|
402
428
|
: undefined;
|
|
403
429
|
|
|
404
430
|
const senderName =
|
|
405
431
|
sourceMessage?.envelope.from[0]?.displayName ??
|
|
406
432
|
sourceMessage?.envelope.from[0]?.normalizedEmail;
|
|
407
433
|
|
|
434
|
+
// The draft this session just created holds what is already on screen, so
|
|
435
|
+
// there is nothing to read back — and reading it back would replace the
|
|
436
|
+
// document under the caret with the server's copy of it.
|
|
437
|
+
const adoptCreatedDraft = useCallback(
|
|
438
|
+
(createdId: string) => {
|
|
439
|
+
setDraftLoaded(true);
|
|
440
|
+
setOutboxMessageId(createdId);
|
|
441
|
+
},
|
|
442
|
+
[setOutboxMessageId],
|
|
443
|
+
);
|
|
444
|
+
|
|
408
445
|
const { saveStatus, saveError, saveDraft, stopAutoSave } = useSaveDraft({
|
|
409
446
|
outboxMessageId,
|
|
410
|
-
onDraftCreated:
|
|
447
|
+
onDraftCreated: adoptCreatedDraft,
|
|
411
448
|
});
|
|
412
449
|
|
|
413
450
|
// Auto-save runs on a debounce, so a failure has no inline call site to
|
|
@@ -475,8 +512,7 @@ export const ComposeForm = ({
|
|
|
475
512
|
if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
|
|
476
513
|
return;
|
|
477
514
|
|
|
478
|
-
const textBody =
|
|
479
|
-
const htmlBody = plateValueToHtml(body);
|
|
515
|
+
const { html: htmlBody, text: textBody } = body;
|
|
480
516
|
|
|
481
517
|
saveDraft({
|
|
482
518
|
accountId: selectedAccountId,
|
|
@@ -515,8 +551,7 @@ export const ComposeForm = ({
|
|
|
515
551
|
let createdThisAttempt = false;
|
|
516
552
|
|
|
517
553
|
if (!messageId) {
|
|
518
|
-
const textBody =
|
|
519
|
-
const htmlBody = plateValueToHtml(body);
|
|
554
|
+
const { html: htmlBody, text: textBody } = body;
|
|
520
555
|
|
|
521
556
|
const outboxMessage = await createMutation
|
|
522
557
|
.mutateAsync({
|
|
@@ -665,7 +700,8 @@ export const ComposeForm = ({
|
|
|
665
700
|
>
|
|
666
701
|
<Suspense fallback={<ComposeBodyFallback />}>
|
|
667
702
|
<LazyComposeBody
|
|
668
|
-
|
|
703
|
+
key={documentGeneration}
|
|
704
|
+
initialHtml={initialHtml}
|
|
669
705
|
onChange={setBody}
|
|
670
706
|
onSubmit={handleSend}
|
|
671
707
|
autoFocus={mode === "new"}
|
|
@@ -27,7 +27,7 @@ const useIsDraftDirty = (): (() => boolean) => {
|
|
|
27
27
|
const subject =
|
|
28
28
|
document.querySelector<HTMLInputElement>("[data-subject-field]")?.value ??
|
|
29
29
|
"";
|
|
30
|
-
const editorEl = document.querySelector(
|
|
30
|
+
const editorEl = document.querySelector('[data-testid="compose-body"]');
|
|
31
31
|
const bodyText = editorEl?.textContent ?? "";
|
|
32
32
|
// Strip signature separator "-- " to avoid false positives
|
|
33
33
|
const cleaned = bodyText.replace(/--\s*/g, "").trim();
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { AutoformatPlugin } from "@platejs/autoformat";
|
|
2
|
-
import {
|
|
3
|
-
BlockquotePlugin,
|
|
4
|
-
BoldPlugin,
|
|
5
|
-
ItalicPlugin,
|
|
6
|
-
} from "@platejs/basic-nodes/react";
|
|
7
|
-
import { LinkPlugin } from "@platejs/link/react";
|
|
8
|
-
import type { Value } from "platejs";
|
|
9
|
-
import { PlateContent, usePlateEditor } from "platejs/react";
|
|
10
|
-
import { useEffect } from "react";
|
|
11
|
-
|
|
12
|
-
export interface PlateEditorProps {
|
|
13
|
-
initialValue?: Value;
|
|
14
|
-
onChange?: (value: Value) => void;
|
|
15
|
-
onSubmit?: () => void;
|
|
16
|
-
autoFocus?: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const EMPTY_VALUE: Value = [{ type: "p", children: [{ text: "" }] }];
|
|
20
|
-
|
|
21
|
-
export const COMPOSE_PLUGINS = [
|
|
22
|
-
BoldPlugin,
|
|
23
|
-
ItalicPlugin,
|
|
24
|
-
LinkPlugin,
|
|
25
|
-
BlockquotePlugin,
|
|
26
|
-
AutoformatPlugin.configure({
|
|
27
|
-
options: {
|
|
28
|
-
rules: [
|
|
29
|
-
{
|
|
30
|
-
match: "**",
|
|
31
|
-
mode: "mark" as const,
|
|
32
|
-
type: "bold",
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
match: "*",
|
|
36
|
-
mode: "mark" as const,
|
|
37
|
-
type: "italic",
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
match: "> ",
|
|
41
|
-
mode: "block" as const,
|
|
42
|
-
type: "blockquote",
|
|
43
|
-
},
|
|
44
|
-
],
|
|
45
|
-
enableUndoOnDelete: true,
|
|
46
|
-
},
|
|
47
|
-
}),
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
export const usePlateComposeEditor = (initialValue?: Value) =>
|
|
51
|
-
usePlateEditor({
|
|
52
|
-
plugins: COMPOSE_PLUGINS,
|
|
53
|
-
value: initialValue ?? EMPTY_VALUE,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
export const PlateEditorContent = ({
|
|
57
|
-
onSubmit,
|
|
58
|
-
autoFocus,
|
|
59
|
-
editor,
|
|
60
|
-
}: Omit<PlateEditorProps, "initialValue" | "onChange"> & {
|
|
61
|
-
editor: ReturnType<typeof usePlateComposeEditor>;
|
|
62
|
-
}) => {
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
if (autoFocus) {
|
|
65
|
-
const timer = setTimeout(() => {
|
|
66
|
-
editor.tf.focus();
|
|
67
|
-
}, 0);
|
|
68
|
-
return () => clearTimeout(timer);
|
|
69
|
-
}
|
|
70
|
-
}, [autoFocus, editor]);
|
|
71
|
-
|
|
72
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
73
|
-
if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && onSubmit) {
|
|
74
|
-
e.preventDefault();
|
|
75
|
-
onSubmit();
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<PlateContent
|
|
81
|
-
className="w-full px-3 py-2 bg-canvas text-sm outline-none min-h-[120px] [&_blockquote]:pl-3 [&_blockquote]:border-l-2 [&_blockquote]:border-fg-subtle/30 [&_blockquote]:text-fg-muted [&_a]:text-accent [&_a]:underline"
|
|
82
|
-
placeholder="Write your message..."
|
|
83
|
-
onKeyDown={handleKeyDown}
|
|
84
|
-
/>
|
|
85
|
-
);
|
|
86
|
-
};
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
BlockquotePlugin,
|
|
3
|
-
BoldPlugin,
|
|
4
|
-
ItalicPlugin,
|
|
5
|
-
} from "@platejs/basic-nodes/react";
|
|
6
|
-
import { insertLink } from "@platejs/link";
|
|
7
|
-
import { Bold, Italic, Link, Quote, Redo2, Undo2 } from "lucide-react";
|
|
8
|
-
import { useEditorRef, useEditorSelector } from "platejs/react";
|
|
9
|
-
|
|
10
|
-
const ToolbarButton = ({
|
|
11
|
-
isActive,
|
|
12
|
-
onClick,
|
|
13
|
-
children,
|
|
14
|
-
title,
|
|
15
|
-
}: {
|
|
16
|
-
isActive: boolean;
|
|
17
|
-
onClick: () => void;
|
|
18
|
-
children: React.ReactNode;
|
|
19
|
-
title: string;
|
|
20
|
-
}) => (
|
|
21
|
-
<button
|
|
22
|
-
type="button"
|
|
23
|
-
onMouseDown={(e) => {
|
|
24
|
-
e.preventDefault();
|
|
25
|
-
onClick();
|
|
26
|
-
}}
|
|
27
|
-
title={title}
|
|
28
|
-
className={`p-1.5 rounded transition-colors ${
|
|
29
|
-
isActive
|
|
30
|
-
? "text-fg bg-accent-2-soft"
|
|
31
|
-
: "text-fg-muted hover:text-fg hover:bg-surface-raised"
|
|
32
|
-
}`}
|
|
33
|
-
>
|
|
34
|
-
{children}
|
|
35
|
-
</button>
|
|
36
|
-
);
|
|
37
|
-
|
|
38
|
-
export const PlateToolbar = () => {
|
|
39
|
-
const editor = useEditorRef();
|
|
40
|
-
|
|
41
|
-
const isBoldActive = useEditorSelector(
|
|
42
|
-
(editor) => !!editor.api.mark(BoldPlugin.key),
|
|
43
|
-
[],
|
|
44
|
-
);
|
|
45
|
-
const isItalicActive = useEditorSelector(
|
|
46
|
-
(editor) => !!editor.api.mark(ItalicPlugin.key),
|
|
47
|
-
[],
|
|
48
|
-
);
|
|
49
|
-
const isBlockquoteActive = useEditorSelector((editor) => {
|
|
50
|
-
const entry = editor.api.block();
|
|
51
|
-
return entry ? entry[0].type === BlockquotePlugin.key : false;
|
|
52
|
-
}, []);
|
|
53
|
-
|
|
54
|
-
const canUndo = useEditorSelector(
|
|
55
|
-
(editor) => editor.history.undos.length > 0,
|
|
56
|
-
[],
|
|
57
|
-
);
|
|
58
|
-
const canRedo = useEditorSelector(
|
|
59
|
-
(editor) => editor.history.redos.length > 0,
|
|
60
|
-
[],
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<div className="flex items-center gap-0.5 px-3 py-1 border-b border-line">
|
|
65
|
-
<ToolbarButton
|
|
66
|
-
isActive={isBoldActive}
|
|
67
|
-
onClick={() => editor.tf.toggleMark(BoldPlugin.key)}
|
|
68
|
-
title="Bold (Ctrl+B)"
|
|
69
|
-
>
|
|
70
|
-
<Bold className="size-4" />
|
|
71
|
-
</ToolbarButton>
|
|
72
|
-
<ToolbarButton
|
|
73
|
-
isActive={isItalicActive}
|
|
74
|
-
onClick={() => editor.tf.toggleMark(ItalicPlugin.key)}
|
|
75
|
-
title="Italic (Ctrl+I)"
|
|
76
|
-
>
|
|
77
|
-
<Italic className="size-4" />
|
|
78
|
-
</ToolbarButton>
|
|
79
|
-
<ToolbarButton
|
|
80
|
-
isActive={false}
|
|
81
|
-
onClick={() => insertLink(editor, { url: "" })}
|
|
82
|
-
title="Link (Ctrl+K)"
|
|
83
|
-
>
|
|
84
|
-
<Link className="size-4" />
|
|
85
|
-
</ToolbarButton>
|
|
86
|
-
<ToolbarButton
|
|
87
|
-
isActive={isBlockquoteActive}
|
|
88
|
-
onClick={() => editor.tf.toggleBlock(BlockquotePlugin.key)}
|
|
89
|
-
title="Blockquote"
|
|
90
|
-
>
|
|
91
|
-
<Quote className="size-4" />
|
|
92
|
-
</ToolbarButton>
|
|
93
|
-
<div className="mx-1.5 h-4 w-px bg-line" />
|
|
94
|
-
<ToolbarButton
|
|
95
|
-
isActive={false}
|
|
96
|
-
onClick={() => editor.undo()}
|
|
97
|
-
title="Undo (Ctrl+Z)"
|
|
98
|
-
>
|
|
99
|
-
<Undo2 className={`size-4 ${!canUndo ? "opacity-40" : ""}`} />
|
|
100
|
-
</ToolbarButton>
|
|
101
|
-
<ToolbarButton
|
|
102
|
-
isActive={false}
|
|
103
|
-
onClick={() => editor.redo()}
|
|
104
|
-
title="Redo (Ctrl+Y)"
|
|
105
|
-
>
|
|
106
|
-
<Redo2 className={`size-4 ${!canRedo ? "opacity-40" : ""}`} />
|
|
107
|
-
</ToolbarButton>
|
|
108
|
-
</div>
|
|
109
|
-
);
|
|
110
|
-
};
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
import DOMPurify from "dompurify";
|
|
2
|
-
|
|
3
|
-
const QUOTE_ALLOWED_TAGS = [
|
|
4
|
-
"p",
|
|
5
|
-
"br",
|
|
6
|
-
"strong",
|
|
7
|
-
"b",
|
|
8
|
-
"em",
|
|
9
|
-
"i",
|
|
10
|
-
"a",
|
|
11
|
-
"blockquote",
|
|
12
|
-
"ul",
|
|
13
|
-
"ol",
|
|
14
|
-
"li",
|
|
15
|
-
];
|
|
16
|
-
|
|
17
|
-
const QUOTE_ALLOWED_ATTR = ["href"];
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Quoted mail renders in the app's own document rather than in the reading
|
|
21
|
-
* pane's sandboxed frame, so a link in it would otherwise navigate the app
|
|
22
|
-
* window itself to wherever the sender points. Launched from a home screen
|
|
23
|
-
* there is no address bar and no back button to return from that, so quoted
|
|
24
|
-
* links leave for a separate context and take no handle on this one with them.
|
|
25
|
-
*
|
|
26
|
-
* Its own DOMPurify instance: the hook below must not reach the default
|
|
27
|
-
* instance any other caller might use.
|
|
28
|
-
*/
|
|
29
|
-
let purifier: ReturnType<typeof DOMPurify> | null = null;
|
|
30
|
-
|
|
31
|
-
const quotePurifier = (): ReturnType<typeof DOMPurify> => {
|
|
32
|
-
if (purifier) return purifier;
|
|
33
|
-
const instance = DOMPurify();
|
|
34
|
-
instance.addHook("afterSanitizeAttributes", (node) => {
|
|
35
|
-
if (node.tagName !== "A") return;
|
|
36
|
-
node.setAttribute("target", "_blank");
|
|
37
|
-
node.setAttribute("rel", "noopener noreferrer nofollow");
|
|
38
|
-
});
|
|
39
|
-
purifier = instance;
|
|
40
|
-
return instance;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export const sanitizeQuoteHtml = (html: string): string =>
|
|
44
|
-
quotePurifier().sanitize(html, {
|
|
45
|
-
ALLOWED_TAGS: QUOTE_ALLOWED_TAGS,
|
|
46
|
-
ALLOWED_ATTR: QUOTE_ALLOWED_ATTR,
|
|
47
|
-
});
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import type { TElement, TText, Value } from "platejs";
|
|
2
|
-
|
|
3
|
-
type PlateNode = TElement | TText;
|
|
4
|
-
|
|
5
|
-
const isText = (node: PlateNode): node is TText =>
|
|
6
|
-
"text" in node && !("children" in node);
|
|
7
|
-
|
|
8
|
-
const escapeHtml = (text: string): string =>
|
|
9
|
-
text
|
|
10
|
-
.replace(/&/g, "&")
|
|
11
|
-
.replace(/</g, "<")
|
|
12
|
-
.replace(/>/g, ">")
|
|
13
|
-
.replace(/"/g, """);
|
|
14
|
-
|
|
15
|
-
const serializeTextNode = (node: TText): string => {
|
|
16
|
-
let html = escapeHtml(node.text);
|
|
17
|
-
if (node.bold) html = `<strong>${html}</strong>`;
|
|
18
|
-
if (node.italic) html = `<em>${html}</em>`;
|
|
19
|
-
return html;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const serializeElement = (node: TElement): string => {
|
|
23
|
-
const children = node.children
|
|
24
|
-
.map((child) =>
|
|
25
|
-
isText(child as PlateNode)
|
|
26
|
-
? serializeTextNode(child as TText)
|
|
27
|
-
: serializeElement(child as TElement),
|
|
28
|
-
)
|
|
29
|
-
.join("");
|
|
30
|
-
|
|
31
|
-
switch (node.type) {
|
|
32
|
-
case "blockquote":
|
|
33
|
-
return `<blockquote>${children}</blockquote>`;
|
|
34
|
-
case "a": {
|
|
35
|
-
const url = (node as TElement & { url?: string }).url ?? "";
|
|
36
|
-
return `<a href="${escapeHtml(url)}">${children}</a>`;
|
|
37
|
-
}
|
|
38
|
-
default:
|
|
39
|
-
return `<p>${children}</p>`;
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export const plateValueToHtml = (value: Value): string =>
|
|
44
|
-
value.map(serializeElement).join("");
|
|
45
|
-
|
|
46
|
-
const extractText = (node: PlateNode): string => {
|
|
47
|
-
if (isText(node)) return node.text;
|
|
48
|
-
return (node.children as PlateNode[]).map(extractText).join("");
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
export const plateValueToText = (value: Value): string =>
|
|
52
|
-
value.map(extractText).join("\n");
|