Skip to content

Commit a6f6231

Browse files
committed
feat(markdown): add url pasting and emoji to undo stack
1 parent a297bdb commit a6f6231

1 file changed

Lines changed: 185 additions & 127 deletions

File tree

Lines changed: 185 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,162 +1,224 @@
1-
import { LoadingOverlay } from "@/components/atoms/LoadingOverlay";
2-
import { useImageCreate } from "@/lib/api/image";
3-
import { useTheme } from "@/lib/hooks/useTheme";
4-
import { getUuid } from "@/lib/utils/utils";
5-
import MDEditor, { commands, MDEditorProps } from "@uiw/react-md-editor";
6-
import { LucideSmilePlus } from "lucide-react";
7-
import { useEffect, useRef, useState } from "react";
8-
import rehypeSanitize from "rehype-sanitize";
9-
import { toast } from "sonner";
10-
import EmojiPicker, { EmojiClickData, Theme } from "emoji-picker-react";
1+
import { LoadingOverlay } from "@/components/atoms/LoadingOverlay"
2+
import { useImageCreate } from "@/lib/api/image"
3+
import { useTheme } from "@/lib/hooks/useTheme"
4+
import { getUuid } from "@/lib/utils/utils"
5+
import MDEditor, { commands, MDEditorProps } from "@uiw/react-md-editor"
6+
import { LucideSmilePlus } from "lucide-react"
7+
import { useEffect, useMemo, useRef, useState } from "react"
8+
import rehypeSanitize from "rehype-sanitize"
9+
import { toast } from "sonner"
10+
import EmojiPicker, { EmojiClickData, Theme } from "emoji-picker-react"
1111

12-
export function MarkdownCombo({ value = "", onChange, ...props }: MDEditorProps) {
13-
const { theme } = useTheme();
12+
type Props = MDEditorProps
1413

15-
const [uploading, setUploading] = useState(false);
16-
const imageCreate = useImageCreate();
14+
type Selection = { start: number; end: number }
1715

18-
const editorRef = useRef<HTMLDivElement>(null);
19-
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
16+
const isHttpUrl = (text: string) => /^https?:\/\/\S+$/i.test(text.trim())
2017

21-
const [emojiOpen, setEmojiOpen] = useState(false);
18+
const canInsertTextWithExecCommand = () => {
19+
// execCommand is deprecated, but is still supported by most browser and works with the browser's undo stack
20+
if (typeof document === "undefined") return false
21+
if (typeof document.execCommand !== "function") return false
22+
if (typeof document.queryCommandSupported !== "function") return true
23+
return document.queryCommandSupported("insertText")
24+
}
25+
26+
export function MarkdownCombo({ value = "", onChange, ...props }: Props) {
27+
const { theme } = useTheme()
28+
29+
const [uploading, setUploading] = useState(false)
30+
const [emojiOpen, setEmojiOpen] = useState(false)
31+
32+
const imageCreate = useImageCreate()
33+
34+
const editorRef = useRef<HTMLDivElement>(null)
35+
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
36+
37+
const selectionRef = useRef<Selection>({ start: 0, end: 0 })
2238

23-
const selectionRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 });
2439
const updateSelection = () => {
25-
const ta = textareaRef.current;
26-
if (!ta) return;
40+
const ta = textareaRef.current
41+
if (!ta) return
42+
2743
selectionRef.current = {
2844
start: ta.selectionStart ?? 0,
2945
end: ta.selectionEnd ?? 0,
30-
};
31-
};
32-
const getSelection = (): string => {
33-
updateSelection();
34-
const { start, end } = selectionRef.current;
35-
return value.slice(start, end);
46+
}
3647
}
48+
49+
const getSelectedText = () => {
50+
updateSelection()
51+
52+
const { start, end } = selectionRef.current
53+
54+
return value.slice(start, end)
55+
}
56+
3757
const setSelection = (start: number, end: number) => {
3858
requestAnimationFrame(() => {
39-
const ta = textareaRef.current;
40-
if (!ta) return;
41-
ta.focus();
42-
ta.setSelectionRange(start, end);
43-
selectionRef.current = { start: start, end: end };
44-
});
45-
};
59+
const ta = textareaRef.current
60+
if (!ta) return
61+
ta.focus()
62+
ta.setSelectionRange(start, end)
63+
selectionRef.current = { start, end }
64+
})
65+
}
66+
4667
const replaceSelection = (replaceText: string) => {
47-
updateSelection();
48-
const { start, end } = selectionRef.current;
68+
updateSelection()
69+
70+
const { start, end } = selectionRef.current
71+
72+
const ta = textareaRef.current
73+
74+
if (!ta) {
75+
const nextValue = value.slice(0, start) + replaceText + value.slice(end)
76+
onChange?.(nextValue)
77+
const nextCursor = start + replaceText.length
78+
setSelection(nextCursor, nextCursor)
79+
80+
return
81+
}
82+
83+
ta.focus()
84+
ta.setSelectionRange(start, end)
4985

50-
const nextValue = value.slice(0, start) + replaceText + value.slice(end);
51-
onChange?.(nextValue);
86+
const execOk = canInsertTextWithExecCommand() && document.execCommand("insertText", false, replaceText)
5287

53-
const nextCursor = start + replaceText.length;
54-
setSelection(nextCursor, nextCursor);
88+
if (execOk) {
89+
updateSelection()
90+
91+
return
92+
}
93+
94+
ta.setRangeText(replaceText, start, end, "end")
95+
selectionRef.current = {
96+
start: start + replaceText.length,
97+
end: start + replaceText.length,
98+
}
99+
100+
// Let react know the input was changed
101+
try {
102+
ta.dispatchEvent(
103+
new InputEvent("input", {
104+
bubbles: true,
105+
inputType: "insertText",
106+
data: replaceText,
107+
})
108+
)
109+
} catch {
110+
ta.dispatchEvent(new Event("input", { bubbles: true }))
111+
}
55112
}
56113

57114
useEffect(() => {
58-
const root = editorRef.current;
59-
if (!root) return;
115+
const root = editorRef.current
116+
if (!root) return
60117

61-
const ta = root.querySelector<HTMLTextAreaElement>(".w-md-editor-text-input");
62-
textareaRef.current = ta;
118+
const ta = root.querySelector<HTMLTextAreaElement>(".w-md-editor-text-input")
119+
textareaRef.current = ta
120+
if (!ta) return
63121

64-
if (!ta) return;
122+
const onAny = () => updateSelection()
65123

66-
const onAny = () => updateSelection();
67-
ta.addEventListener("keyup", onAny);
68-
ta.addEventListener("mouseup", onAny);
69-
ta.addEventListener("select", onAny);
70-
ta.addEventListener("focus", onAny);
124+
ta.addEventListener("keyup", onAny)
125+
ta.addEventListener("mouseup", onAny)
126+
ta.addEventListener("select", onAny)
127+
ta.addEventListener("focus", onAny)
71128

72-
updateSelection();
129+
updateSelection()
73130

74131
return () => {
75-
ta.removeEventListener("keyup", onAny);
76-
ta.removeEventListener("mouseup", onAny);
77-
ta.removeEventListener("select", onAny);
78-
ta.removeEventListener("focus", onAny);
79-
};
80-
}, []);
132+
ta.removeEventListener("keyup", onAny)
133+
ta.removeEventListener("mouseup", onAny)
134+
ta.removeEventListener("select", onAny)
135+
ta.removeEventListener("focus", onAny)
136+
}
137+
}, [])
81138

82139
useEffect(() => {
83-
if (!emojiOpen) return;
140+
if (!emojiOpen) return
84141

85142
const onDocMouseDown = (e: MouseEvent) => {
86-
const root = editorRef.current;
87-
if (!root) return;
88-
if (!root.contains(e.target as Node)) setEmojiOpen(false);
89-
};
143+
const root = editorRef.current
144+
if (!root) return
145+
if (!root.contains(e.target as Node)) setEmojiOpen(false)
146+
}
90147

91148
const onKeyDown = (e: KeyboardEvent) => {
92-
if (e.key === "Escape") setEmojiOpen(false);
93-
};
149+
if (e.key === "Escape") setEmojiOpen(false)
150+
}
151+
152+
document.addEventListener("mousedown", onDocMouseDown)
153+
document.addEventListener("keydown", onKeyDown)
94154

95-
document.addEventListener("mousedown", onDocMouseDown);
96-
document.addEventListener("keydown", onKeyDown);
97155
return () => {
98-
document.removeEventListener("mousedown", onDocMouseDown);
99-
document.removeEventListener("keydown", onKeyDown);
100-
};
101-
}, [emojiOpen]);
156+
document.removeEventListener("mousedown", onDocMouseDown)
157+
document.removeEventListener("keydown", onKeyDown)
158+
}
159+
}, [emojiOpen])
102160

103161
useEffect(() => {
104-
const container = editorRef.current;
105-
if (!container) return;
106-
107-
const handlePaste = async (e: ClipboardEvent) => {
108-
// handle pasting http urls (for auto hyperlink)
109-
const pasteText = e.clipboardData?.getData("text/plain");
110-
if (pasteText?.startsWith("https://")) {
111-
112-
// only do special handling if there was text selected
113-
// otherwise use the normal handling, so the undo/redo is preserved
114-
const selectedText = getSelection();
115-
if (selectedText) {
116-
e.preventDefault();
117-
const replacementText = `[${selectedText}](${pasteText})`;
118-
replaceSelection(replacementText);
119-
return;
162+
const container = editorRef.current
163+
if (!container) return
164+
165+
const handlePaste = (e: ClipboardEvent) => {
166+
const pasteText = e.clipboardData?.getData("text/plain") ?? ""
167+
const selectedText = getSelectedText()
168+
169+
const imageItems = Array.from(e.clipboardData?.items ?? []).filter((i) => i.type.startsWith("image/"))
170+
171+
switch (true) {
172+
case Boolean(selectedText) && isHttpUrl(pasteText): {
173+
e.preventDefault()
174+
replaceSelection(`[${selectedText}](${pasteText.trim()})`)
175+
176+
return
120177
}
121-
}
122178

123-
const allItems = e.clipboardData?.items;
124-
const items = [...(allItems ?? [])].filter((i) => i.type.startsWith("image/"));
125-
if (!items.length) return;
126-
127-
setUploading(true);
128-
129-
for (const item of items) {
130-
e.preventDefault();
131-
const file = item.getAsFile();
132-
if (!file) continue;
133-
134-
imageCreate.mutate(
135-
{ name: getUuid(), file },
136-
{
137-
onSuccess: (image) => {
138-
const baseUrl = window.location.origin;
139-
const selectedText = getSelection() || "pasted image";
140-
const replacementText = `![${selectedText}](${baseUrl}/api/v1/image/${image.id})`;
141-
replaceSelection(replacementText);
142-
},
143-
onError: () => toast.error("Failed", { description: "Probably an unsupported file type" }),
144-
onSettled: () => setUploading(false),
179+
case imageItems.length > 0: {
180+
e.preventDefault()
181+
setUploading(true)
182+
183+
for (const item of imageItems) {
184+
const file = item.getAsFile()
185+
if (!file) continue
186+
187+
imageCreate.mutate(
188+
{ name: getUuid(), file },
189+
{
190+
onSuccess: (image) => {
191+
const baseUrl = window.location.origin
192+
const alt = getSelectedText() || "pasted image"
193+
replaceSelection(`![${alt}](${baseUrl}/api/v1/image/${image.id})`)
194+
},
195+
onError: () => toast.error("Failed", { description: "Probably an unsupported file type" }),
196+
onSettled: () => setUploading(false),
197+
}
198+
)
145199
}
146-
);
200+
201+
return
202+
}
203+
204+
default:
205+
return
147206
}
148-
};
207+
}
208+
209+
container.addEventListener("paste", handlePaste)
149210

150-
container.addEventListener("paste", handlePaste);
151-
return () => container.removeEventListener("paste", handlePaste);
152-
}, [value, onChange]); // eslint-disable-line react-hooks/exhaustive-deps
211+
return () => container.removeEventListener("paste", handlePaste)
212+
}, [value, onChange, imageCreate])
153213

154-
const toggleEmojiSelector = () => setEmojiOpen((v) => !v);
214+
const emojiTheme = useMemo(() => (theme === "dark" ? Theme.DARK : Theme.LIGHT), [theme])
215+
216+
const toggleEmojiSelector = () => setEmojiOpen((v) => !v)
155217

156218
const handleEmojiClick = (emojiData: EmojiClickData) => {
157-
replaceSelection(emojiData.emoji);
158-
setEmojiOpen(false);
159-
};
219+
replaceSelection(emojiData.emoji)
220+
setEmojiOpen(false)
221+
}
160222

161223
return (
162224
<LoadingOverlay loading={uploading}>
@@ -165,8 +227,8 @@ export function MarkdownCombo({ value = "", onChange, ...props }: MDEditorProps)
165227
value={value}
166228
height={600}
167229
onChange={(v) => {
168-
onChange?.(v);
169-
requestAnimationFrame(updateSelection);
230+
onChange?.(v)
231+
requestAnimationFrame(updateSelection)
170232
}}
171233
previewOptions={{
172234
rehypePlugins: [[rehypeSanitize]],
@@ -179,8 +241,8 @@ export function MarkdownCombo({ value = "", onChange, ...props }: MDEditorProps)
179241
type="button"
180242
className="mr-1 inline-flex items-center"
181243
onMouseDown={(e) => {
182-
e.preventDefault();
183-
updateSelection();
244+
e.preventDefault()
245+
updateSelection()
184246
}}
185247
onClick={toggleEmojiSelector}
186248
aria-label="Insert emoji"
@@ -195,15 +257,11 @@ export function MarkdownCombo({ value = "", onChange, ...props }: MDEditorProps)
195257

196258
{emojiOpen && (
197259
<div className="absolute z-50 top-10 right-2">
198-
<EmojiPicker
199-
onEmojiClick={handleEmojiClick}
200-
autoFocusSearch
201-
theme={theme === "dark" ? Theme.DARK : Theme.LIGHT}
202-
/>
260+
<EmojiPicker onEmojiClick={handleEmojiClick} autoFocusSearch theme={emojiTheme} />
203261
</div>
204262
)}
205263
</div>
206264
</LoadingOverlay>
207-
);
265+
)
208266
}
209267

0 commit comments

Comments
 (0)