rich text

This commit is contained in:
Arjun 2026-05-13 22:51:14 +05:30
parent ce15b7d929
commit 38b1f78ede
2 changed files with 346 additions and 18 deletions

View file

@ -612,19 +612,179 @@
font: inherit;
}
.gmail-compose-card textarea {
.gmail-compose-toolbar {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
justify-content: center;
}
.gmail-compose-link-popover {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--gm-border);
background: var(--gm-bg-input);
}
.gmail-compose-link-popover input {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 8px;
border: 1px solid var(--gm-border-strong);
border-radius: 4px;
background: var(--gm-bg-card);
color: var(--gm-text);
font: inherit;
font-size: 12px;
outline: none;
}
.gmail-compose-link-popover input:focus {
border-color: var(--gm-accent);
}
.gmail-compose-link-popover button {
height: 26px;
padding: 0 10px;
border: 1px solid var(--gm-border-strong);
border-radius: 4px;
background: var(--gm-bg-pill);
color: var(--gm-text);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.gmail-compose-link-popover button:hover {
background: var(--gm-bg-pill-hover);
}
.gmail-compose-link-popover-apply {
background: var(--gm-accent) !important;
border-color: var(--gm-accent) !important;
color: var(--gm-accent-fg) !important;
font-weight: 600;
}
.gmail-compose-link-popover-apply:hover {
background: var(--gm-accent-hover) !important;
border-color: var(--gm-accent-hover) !important;
}
.gmail-compose-tool {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.gmail-compose-tool:hover {
background: var(--gm-bg-pill-hover);
color: var(--gm-text);
}
.gmail-compose-tool.is-active {
background: var(--gm-bg-pill-hover);
color: var(--gm-accent);
}
.gmail-compose-tool-sep {
display: inline-block;
width: 1px;
height: 18px;
margin: 0 6px;
background: var(--gm-border-strong);
}
.gmail-compose-editor {
display: block;
width: 100%;
min-height: 120px;
border: none;
max-height: 360px;
overflow-y: auto;
}
.gmail-compose-content {
outline: none;
resize: none;
min-height: 120px;
padding: 12px;
background: transparent;
color: var(--gm-text);
font: 13px/1.55 inherit;
}
.gmail-compose-content p {
margin: 0;
}
.gmail-compose-content p + p,
.gmail-compose-content p + ul,
.gmail-compose-content p + ol,
.gmail-compose-content p + blockquote {
margin-top: 8px;
}
.gmail-compose-content ul,
.gmail-compose-content ol {
margin: 0;
padding-left: 22px;
}
.gmail-compose-content ul {
list-style: disc;
}
.gmail-compose-content ol {
list-style: decimal;
}
.gmail-compose-content li {
margin: 2px 0;
}
.gmail-compose-content li > p {
margin: 0;
}
.gmail-compose-content blockquote {
margin: 4px 0;
padding-left: 12px;
border-left: 2px solid var(--gm-border-strong);
color: var(--gm-text-muted);
}
.gmail-compose-content a {
color: var(--gm-accent);
text-decoration: underline;
}
.gmail-compose-content code {
padding: 1px 4px;
border-radius: 3px;
background: var(--gm-bg-pill-hover);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
}
.gmail-compose-content p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--gm-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.gmail-compose-actions {
display: flex;
align-items: center;

View file

@ -1,5 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Forward, LoaderIcon, RefreshCw, Reply, Search, Send } from 'lucide-react'
import { Bold, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Quote, RefreshCw, Reply, Search, Send, Strikethrough } from 'lucide-react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
import type { blocks } from '@x/shared'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
@ -283,6 +287,105 @@ async function mapWithConcurrency<T, R>(
type ComposeMode = 'reply' | 'forward'
function ComposeToolbarButton({
editor,
command,
isActive,
label,
children,
}: {
editor: Editor
command: () => void
isActive: boolean
label: string
children: React.ReactNode
}) {
return (
<button
type="button"
className={cn('gmail-compose-tool', isActive && 'is-active')}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
command()
editor.chain().focus().run()
}}
aria-label={label}
aria-pressed={isActive}
title={label}
>
{children}
</button>
)
}
function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) {
return (
<div className="gmail-compose-toolbar">
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
label="Bold"
>
<Bold size={14} />
</ComposeToolbarButton>
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
label="Italic"
>
<Italic size={14} />
</ComposeToolbarButton>
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
label="Strikethrough"
>
<Strikethrough size={14} />
</ComposeToolbarButton>
<span className="gmail-compose-tool-sep" />
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
label="Bulleted list"
>
<List size={14} />
</ComposeToolbarButton>
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
label="Numbered list"
>
<ListOrdered size={14} />
</ComposeToolbarButton>
<ComposeToolbarButton
editor={editor}
command={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
label="Quote"
>
<Quote size={14} />
</ComposeToolbarButton>
<span className="gmail-compose-tool-sep" />
<button
type="button"
className={cn('gmail-compose-tool', editor.isActive('link') && 'is-active')}
onMouseDown={(event) => event.preventDefault()}
onClick={onOpenLink}
aria-label="Link"
aria-pressed={editor.isActive('link')}
title="Link"
>
<LinkIcon size={14} />
</button>
</div>
)
}
function ComposeBox({
mode,
thread,
@ -293,16 +396,64 @@ function ComposeBox({
onClose: () => void
}) {
const latest = latestMessage(thread)
const [body, setBody] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const to = mode === 'reply' ? extractAddress(latest?.from) : ''
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({ openOnClick: false, autolink: true }),
Placeholder.configure({
placeholder: mode === 'reply' ? 'Write your reply…' : 'Write a message…',
}),
],
editorProps: {
attributes: { class: 'gmail-compose-content' },
},
content: '',
})
const [linkOpen, setLinkOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const savedSelectionRef = useRef<{ from: number; to: number } | null>(null)
const linkInputRef = useRef<HTMLInputElement>(null)
const openLink = () => {
if (!editor) return
const { from, to: selTo } = editor.state.selection
savedSelectionRef.current = { from, to: selTo }
const existing = editor.getAttributes('link').href as string | undefined
setLinkUrl(existing || 'https://')
setLinkOpen(true)
}
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.max(120, el.scrollHeight)}px`
}, [body])
if (!linkOpen) return
const id = window.setTimeout(() => linkInputRef.current?.select(), 0)
return () => window.clearTimeout(id)
}, [linkOpen])
const applyLink = () => {
if (!editor) {
setLinkOpen(false)
return
}
const sel = savedSelectionRef.current
setLinkOpen(false)
if (!sel) return
const trimmed = linkUrl.trim()
if (!trimmed || trimmed === 'https://') {
editor.chain().focus().setTextSelection(sel).extendMarkRange('link').unsetLink().run()
return
}
const href = /^[a-z]+:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
editor.chain().focus().setTextSelection(sel).extendMarkRange('link').setLink({ href }).run()
}
const cancelLink = () => {
setLinkOpen(false)
const sel = savedSelectionRef.current
if (editor && sel) editor.chain().focus().setTextSelection(sel).run()
}
return (
<div className="gmail-compose-card">
@ -320,12 +471,28 @@ function ComposeBox({
<input value={`Fwd: ${thread.subject || '(No subject)'}`} readOnly />
</div>
)}
<textarea
ref={textareaRef}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={mode === 'reply' ? 'Write your reply...' : 'Write a message...'}
/>
<EditorContent editor={editor} className="gmail-compose-editor" />
{linkOpen && (
<div className="gmail-compose-link-popover" onMouseDown={(event) => event.preventDefault()}>
<input
ref={linkInputRef}
value={linkUrl}
onChange={(event) => setLinkUrl(event.target.value)}
placeholder="https://example.com"
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
applyLink()
} else if (event.key === 'Escape') {
event.preventDefault()
cancelLink()
}
}}
/>
<button type="button" className="gmail-compose-link-popover-apply" onClick={applyLink}>Apply</button>
<button type="button" className="gmail-compose-link-popover-cancel" onClick={cancelLink}>Cancel</button>
</div>
)}
<div className="gmail-compose-actions">
<button
type="button"
@ -337,6 +504,7 @@ function ComposeBox({
<Send size={15} />
Send
</button>
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
</div>
</div>