mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
rich text
This commit is contained in:
parent
836fec0dd3
commit
8e613e986f
2 changed files with 346 additions and 18 deletions
|
|
@ -612,19 +612,179 @@
|
||||||
font: inherit;
|
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;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 120px;
|
max-height: 360px;
|
||||||
border: none;
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gmail-compose-content {
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
min-height: 120px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--gm-text);
|
color: var(--gm-text);
|
||||||
font: 13px/1.55 inherit;
|
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 {
|
.gmail-compose-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
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 type { blocks } from '@x/shared'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
@ -283,6 +287,105 @@ async function mapWithConcurrency<T, R>(
|
||||||
|
|
||||||
type ComposeMode = 'reply' | 'forward'
|
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({
|
function ComposeBox({
|
||||||
mode,
|
mode,
|
||||||
thread,
|
thread,
|
||||||
|
|
@ -293,16 +396,64 @@ function ComposeBox({
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const latest = latestMessage(thread)
|
const latest = latestMessage(thread)
|
||||||
const [body, setBody] = useState('')
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
||||||
const to = mode === 'reply' ? extractAddress(latest?.from) : ''
|
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(() => {
|
useEffect(() => {
|
||||||
const el = textareaRef.current
|
if (!linkOpen) return
|
||||||
if (!el) return
|
const id = window.setTimeout(() => linkInputRef.current?.select(), 0)
|
||||||
el.style.height = 'auto'
|
return () => window.clearTimeout(id)
|
||||||
el.style.height = `${Math.max(120, el.scrollHeight)}px`
|
}, [linkOpen])
|
||||||
}, [body])
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="gmail-compose-card">
|
<div className="gmail-compose-card">
|
||||||
|
|
@ -320,12 +471,28 @@ function ComposeBox({
|
||||||
<input value={`Fwd: ${thread.subject || '(No subject)'}`} readOnly />
|
<input value={`Fwd: ${thread.subject || '(No subject)'}`} readOnly />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<EditorContent editor={editor} className="gmail-compose-editor" />
|
||||||
ref={textareaRef}
|
{linkOpen && (
|
||||||
value={body}
|
<div className="gmail-compose-link-popover" onMouseDown={(event) => event.preventDefault()}>
|
||||||
onChange={(event) => setBody(event.target.value)}
|
<input
|
||||||
placeholder={mode === 'reply' ? 'Write your reply...' : 'Write a message...'}
|
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">
|
<div className="gmail-compose-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -337,6 +504,7 @@ function ComposeBox({
|
||||||
<Send size={15} />
|
<Send size={15} />
|
||||||
Send
|
Send
|
||||||
</button>
|
</button>
|
||||||
|
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
|
||||||
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
|
<button type="button" className="gmail-compose-link" onClick={onClose}>Discard</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue