mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
rich text
This commit is contained in:
parent
ce15b7d929
commit
38b1f78ede
2 changed files with 346 additions and 18 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue