Browse Source

fix editor responsiveness and emoji picker

imwald
Silberengel 1 week ago
parent
commit
e95d8fd0fb
  1. 17
      src/components/EmojiPicker/index.tsx
  2. 33
      src/components/EmojiPickerDialog/index.tsx
  3. 32
      src/components/PostEditor/PostContent.tsx
  4. 2
      src/components/PostEditor/PostTextarea/index.tsx
  5. 2
      src/components/PostEditor/index.tsx
  6. 8
      src/index.css
  7. 24
      src/lib/emoji-picker-preload.ts

17
src/components/EmojiPicker/index.tsx

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
import { EMOJI_PICKER_DATA_SOURCE } from '@/lib/emoji-picker-data-source'
import { preloadEmojiPickerModule } from '@/lib/emoji-picker-preload'
import { DEFAULT_LIKE_REACTION_CONTENT, DEFAULT_LIKE_REACTION_DISPLAY_EMOJI, DEFAULT_SUGGESTED_EMOJIS } from '@/lib/like-reaction-emojis'
import { recordEmojiUsed } from '@/lib/recently-used-emojis'
import { useNostr } from '@/providers/NostrProvider'
@ -25,6 +26,7 @@ export default function EmojiPicker({ @@ -25,6 +26,7 @@ export default function EmojiPicker({
reactionsDefaultOpen ? 'reactions' : 'full'
)
const [customEmojiTick, setCustomEmojiTick] = useState(0)
const [pickerReady, setPickerReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<(HTMLElement & { customEmoji: unknown[] }) | null>(null)
@ -44,8 +46,9 @@ export default function EmojiPicker({ @@ -44,8 +46,9 @@ export default function EmojiPicker({
if (mode !== 'full') return
let cancelled = false
setPickerReady(false)
import('emoji-picker-element').then(({ Picker }) => {
preloadEmojiPickerModule().then(({ Picker }) => {
if (cancelled || !containerRef.current) return
const picker = new Picker({
@ -110,10 +113,12 @@ export default function EmojiPicker({ @@ -110,10 +113,12 @@ export default function EmojiPicker({
picker.addEventListener('emoji-click', handleClick)
containerRef.current.appendChild(picker)
if (!cancelled) setPickerReady(true)
})
return () => {
cancelled = true
setPickerReady(false)
if (pickerRef.current) {
pickerRef.current.remove()
pickerRef.current = null
@ -196,8 +201,14 @@ export default function EmojiPicker({ @@ -196,8 +201,14 @@ export default function EmojiPicker({
{ownEmojisRow}
<div
ref={containerRef}
className="h-[min(350px,50dvh)] min-h-[280px] w-full min-w-[280px] max-w-[350px] shrink-0"
/>
className="relative h-[min(320px,45dvh)] min-h-[240px] w-full min-w-[280px] max-w-[350px] shrink-0"
>
{!pickerReady ? (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
</div>
) : null}
</div>
</div>
)
}

33
src/components/EmojiPickerDialog/index.tsx

@ -4,9 +4,10 @@ import { @@ -4,9 +4,10 @@ import {
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { preloadEmojiPicker } from '@/lib/emoji-picker-preload'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TEmoji } from '@/types'
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import EmojiPicker from '../EmojiPicker'
export default function EmojiPickerDialog({
@ -21,15 +22,35 @@ export default function EmojiPickerDialog({ @@ -21,15 +22,35 @@ export default function EmojiPickerDialog({
}) {
const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false)
/** Keep picker mounted after first open so emoji-picker-element is not cold-started every time. */
const [pickerMounted, setPickerMounted] = useState(false)
useEffect(() => {
if (open) setPickerMounted(true)
}, [open])
useEffect(() => {
void preloadEmojiPicker()
}, [])
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next)
}, [])
if (isSmallScreen) {
return (
<Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}>
<Drawer
open={open}
onOpenChange={handleOpenChange}
handleOnly
shouldScaleBackground={false}
repositionInputs={false}
>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
dragHandle="vaul"
portalContainer={portalContainer}
className="max-h-[min(88dvh,calc(100dvh-5rem))] px-2"
className="max-h-[min(60dvh,calc(100dvh-8rem))] px-2 pb-2"
onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return
@ -39,8 +60,8 @@ export default function EmojiPickerDialog({ @@ -39,8 +60,8 @@ export default function EmojiPickerDialog({
<DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader>
<div className="flex w-full max-w-[100vw] min-w-0 min-h-0 max-h-[min(72dvh,calc(100dvh-6rem))] flex-col overflow-hidden pb-1">
{open ? (
<div className="flex w-full max-w-[100vw] min-w-0 min-h-0 flex-col overflow-hidden pb-1">
{pickerMounted ? (
<EmojiPicker
onEmojiClick={(emoji, e) => {
e.stopPropagation()
@ -56,7 +77,7 @@ export default function EmojiPickerDialog({ @@ -56,7 +77,7 @@ export default function EmojiPickerDialog({
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
side="top"

32
src/components/PostEditor/PostContent.tsx

@ -174,6 +174,22 @@ function formatMarkupImageAtCursor(url: string, asciidoc: boolean): string { @@ -174,6 +174,22 @@ function formatMarkupImageAtCursor(url: string, asciidoc: boolean): string {
return `![image](${safe})`
}
/** On mobile, title + kind-specific fields scroll in a capped header; the editor keeps the rest. */
function ComposerHeaderScroll({
enabled,
children
}: {
enabled: boolean
children: React.ReactNode
}) {
if (!enabled) return <>{children}</>
return (
<div className="flex max-h-[min(36dvh,16rem)] min-h-0 shrink-0 flex-col gap-2 overflow-y-auto overscroll-y-contain">
{children}
</div>
)
}
export default function PostContent({
open,
defaultContent = '',
@ -2539,11 +2555,10 @@ export default function PostContent({ @@ -2539,11 +2555,10 @@ export default function PostContent({
<div
className={cn(
'min-w-0',
isSmallScreen
? 'flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-y-contain'
: 'space-y-2'
isSmallScreen ? 'flex min-h-0 flex-1 flex-col gap-2' : 'space-y-2'
)}
>
<ComposerHeaderScroll enabled={isSmallScreen}>
{/* Dynamic Title based on mode */}
<div className="text-lg font-semibold">
{(() => {
@ -3378,8 +3393,13 @@ export default function PostContent({ @@ -3378,8 +3393,13 @@ export default function PostContent({
</div>
</div>
)}
</ComposerHeaderScroll>
<div className={cn(isSmallScreen && 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden')}>
<div
className={cn(
isSmallScreen && 'flex min-h-[min(36dvh,17rem)] min-w-0 flex-1 flex-col overflow-hidden'
)}
>
<PostTextarea
ref={textareaRef}
text={text}
@ -3391,7 +3411,7 @@ export default function PostContent({ @@ -3391,7 +3411,7 @@ export default function PostContent({
isPoll
? 'min-h-20'
: isSmallScreen
? 'min-h-0'
? 'h-full min-h-0'
: 'min-h-52',
isDiscussionThread && threadErrors.content && 'border-destructive'
)}
@ -3752,7 +3772,7 @@ export default function PostContent({ @@ -3752,7 +3772,7 @@ export default function PostContent({
className={cn(
'space-y-2 min-w-0',
isSmallScreen &&
'sticky bottom-0 z-10 shrink-0 border-t border-border bg-background pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]'
'z-10 shrink-0 border-t border-border bg-background pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom,0px))]'
)}
>
<div className="flex min-w-0 w-full items-center gap-1.5">

2
src/components/PostEditor/PostTextarea/index.tsx

@ -180,7 +180,7 @@ const PostTextarea = forwardRef< @@ -180,7 +180,7 @@ const PostTextarea = forwardRef<
() =>
cn(
'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
isSmallScreen && 'min-h-0 flex-1 overflow-y-auto overscroll-y-contain',
isSmallScreen && 'h-full min-h-0 flex-1 overflow-y-auto overscroll-y-contain',
className
),
[className, isSmallScreen]

2
src/components/PostEditor/index.tsx

@ -15,6 +15,7 @@ import { @@ -15,6 +15,7 @@ import {
} from '@/components/ui/sheet'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { pubkeyToNpub } from '@/lib/pubkey'
import { preloadEmojiPicker } from '@/lib/emoji-picker-preload'
import postEditor from '@/services/post-editor.service'
import { Event } from 'nostr-tools'
import postEditorService from '@/services/post-editor.service'
@ -67,6 +68,7 @@ export default function PostEditor({ @@ -67,6 +68,7 @@ export default function PostEditor({
useEffect(() => {
if (!open) return
postEditorService.setComposerShellOpen(true)
void preloadEmojiPicker()
return () => postEditorService.setComposerShellOpen(false)
}, [open])

8
src/index.css

@ -103,11 +103,17 @@ @@ -103,11 +103,17 @@
}
@media (max-width: 768px) {
/* Mobile composer: scroll inside the bordered editor instead of painting past it. */
/* Mobile composer: fill the editor slot and scroll inside the bordered surface. */
.tiptap.flex-col {
display: flex;
flex-direction: column;
flex: 1 1 0%;
height: 100%;
min-height: 0;
}
.tiptap.flex-col .ProseMirror {
flex: 1 1 0%;
height: 100%;
min-height: 0;
max-height: 100%;
overflow-y: auto;

24
src/lib/emoji-picker-preload.ts

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
import { EMOJI_PICKER_DATA_SOURCE } from '@/lib/emoji-picker-data-source'
let modulePromise: Promise<typeof import('emoji-picker-element')> | null = null
let dataPromise: Promise<unknown> | null = null
/** Warm the emoji-picker-element chunk while the composer is open. */
export function preloadEmojiPickerModule() {
if (!modulePromise) {
modulePromise = import('emoji-picker-element')
}
return modulePromise
}
/** Prime the bundled emoji database so the web component's fetch hits cache. */
export function preloadEmojiPickerData() {
if (!dataPromise) {
dataPromise = fetch(EMOJI_PICKER_DATA_SOURCE).then((r) => r.json())
}
return dataPromise
}
export function preloadEmojiPicker() {
return Promise.all([preloadEmojiPickerModule(), preloadEmojiPickerData()])
}
Loading…
Cancel
Save