diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 2cf90ae3..b5df52bf 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -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; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 7d607ce2..7f92012e 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -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( type ComposeMode = 'reply' | 'forward' +function ComposeToolbarButton({ + editor, + command, + isActive, + label, + children, +}: { + editor: Editor + command: () => void + isActive: boolean + label: string + children: React.ReactNode +}) { + return ( + + ) +} + +function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => void }) { + return ( +
+ editor.chain().focus().toggleBold().run()} + isActive={editor.isActive('bold')} + label="Bold" + > + + + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive('italic')} + label="Italic" + > + + + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive('strike')} + label="Strikethrough" + > + + + + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive('bulletList')} + label="Bulleted list" + > + + + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive('orderedList')} + label="Numbered list" + > + + + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive('blockquote')} + label="Quote" + > + + + + +
+ ) +} + function ComposeBox({ mode, thread, @@ -293,16 +396,64 @@ function ComposeBox({ onClose: () => void }) { const latest = latestMessage(thread) - const [body, setBody] = useState('') - const textareaRef = useRef(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(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 (
@@ -320,12 +471,28 @@ function ComposeBox({
)} -