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. 34
      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 @@
import { EMOJI_PICKER_DATA_SOURCE } from '@/lib/emoji-picker-data-source' 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 { 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 { recordEmojiUsed } from '@/lib/recently-used-emojis'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
@ -25,6 +26,7 @@ export default function EmojiPicker({
reactionsDefaultOpen ? 'reactions' : 'full' reactionsDefaultOpen ? 'reactions' : 'full'
) )
const [customEmojiTick, setCustomEmojiTick] = useState(0) const [customEmojiTick, setCustomEmojiTick] = useState(0)
const [pickerReady, setPickerReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const pickerRef = useRef<(HTMLElement & { customEmoji: unknown[] }) | null>(null) const pickerRef = useRef<(HTMLElement & { customEmoji: unknown[] }) | null>(null)
@ -44,8 +46,9 @@ export default function EmojiPicker({
if (mode !== 'full') return if (mode !== 'full') return
let cancelled = false let cancelled = false
setPickerReady(false)
import('emoji-picker-element').then(({ Picker }) => { preloadEmojiPickerModule().then(({ Picker }) => {
if (cancelled || !containerRef.current) return if (cancelled || !containerRef.current) return
const picker = new Picker({ const picker = new Picker({
@ -110,10 +113,12 @@ export default function EmojiPicker({
picker.addEventListener('emoji-click', handleClick) picker.addEventListener('emoji-click', handleClick)
containerRef.current.appendChild(picker) containerRef.current.appendChild(picker)
if (!cancelled) setPickerReady(true)
}) })
return () => { return () => {
cancelled = true cancelled = true
setPickerReady(false)
if (pickerRef.current) { if (pickerRef.current) {
pickerRef.current.remove() pickerRef.current.remove()
pickerRef.current = null pickerRef.current = null
@ -196,8 +201,14 @@ export default function EmojiPicker({
{ownEmojisRow} {ownEmojisRow}
<div <div
ref={containerRef} 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> </div>
) )
} }

33
src/components/EmojiPickerDialog/index.tsx

@ -4,9 +4,10 @@ import {
DropdownMenuContent, DropdownMenuContent,
DropdownMenuTrigger DropdownMenuTrigger
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { preloadEmojiPicker } from '@/lib/emoji-picker-preload'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import EmojiPicker from '../EmojiPicker' import EmojiPicker from '../EmojiPicker'
export default function EmojiPickerDialog({ export default function EmojiPickerDialog({
@ -21,15 +22,35 @@ export default function EmojiPickerDialog({
}) { }) {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const [open, setOpen] = useState(false) 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) { if (isSmallScreen) {
return ( return (
<Drawer open={open} onOpenChange={setOpen} handleOnly shouldScaleBackground={false}> <Drawer
open={open}
onOpenChange={handleOpenChange}
handleOnly
shouldScaleBackground={false}
repositionInputs={false}
>
<DrawerTrigger asChild>{children}</DrawerTrigger> <DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent <DrawerContent
dragHandle="vaul" dragHandle="vaul"
portalContainer={portalContainer} 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) => { onPointerDownOutside={(e) => {
const t = e.target as HTMLElement | null const t = e.target as HTMLElement | null
if (t?.closest?.('[data-vaul-overlay]')) return if (t?.closest?.('[data-vaul-overlay]')) return
@ -39,8 +60,8 @@ export default function EmojiPickerDialog({
<DrawerHeader className="sr-only"> <DrawerHeader className="sr-only">
<DrawerTitle>Emoji Picker</DrawerTitle> <DrawerTitle>Emoji Picker</DrawerTitle>
</DrawerHeader> </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"> <div className="flex w-full max-w-[100vw] min-w-0 min-h-0 flex-col overflow-hidden pb-1">
{open ? ( {pickerMounted ? (
<EmojiPicker <EmojiPicker
onEmojiClick={(emoji, e) => { onEmojiClick={(emoji, e) => {
e.stopPropagation() e.stopPropagation()
@ -56,7 +77,7 @@ export default function EmojiPickerDialog({
} }
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
side="top" side="top"

34
src/components/PostEditor/PostContent.tsx

@ -174,6 +174,22 @@ function formatMarkupImageAtCursor(url: string, asciidoc: boolean): string {
return `![image](${safe})` 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({ export default function PostContent({
open, open,
defaultContent = '', defaultContent = '',
@ -2539,11 +2555,10 @@ export default function PostContent({
<div <div
className={cn( className={cn(
'min-w-0', 'min-w-0',
isSmallScreen isSmallScreen ? 'flex min-h-0 flex-1 flex-col gap-2' : 'space-y-2'
? 'flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto overscroll-y-contain'
: 'space-y-2'
)} )}
> >
<ComposerHeaderScroll enabled={isSmallScreen}>
{/* Dynamic Title based on mode */} {/* Dynamic Title based on mode */}
<div className="text-lg font-semibold"> <div className="text-lg font-semibold">
{(() => { {(() => {
@ -3378,8 +3393,13 @@ export default function PostContent({
</div> </div>
</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 <PostTextarea
ref={textareaRef} ref={textareaRef}
text={text} text={text}
@ -3391,7 +3411,7 @@ export default function PostContent({
isPoll isPoll
? 'min-h-20' ? 'min-h-20'
: isSmallScreen : isSmallScreen
? 'min-h-0' ? 'h-full min-h-0'
: 'min-h-52', : 'min-h-52',
isDiscussionThread && threadErrors.content && 'border-destructive' isDiscussionThread && threadErrors.content && 'border-destructive'
)} )}
@ -3752,7 +3772,7 @@ export default function PostContent({
className={cn( className={cn(
'space-y-2 min-w-0', 'space-y-2 min-w-0',
isSmallScreen && 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"> <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<
() => () =>
cn( cn(
'border rounded-lg p-3 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', '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
), ),
[className, isSmallScreen] [className, isSmallScreen]

2
src/components/PostEditor/index.tsx

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

8
src/index.css

@ -103,11 +103,17 @@
} }
@media (max-width: 768px) { @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 { .tiptap.flex-col {
display: flex;
flex-direction: column;
flex: 1 1 0%;
height: 100%;
min-height: 0; min-height: 0;
} }
.tiptap.flex-col .ProseMirror { .tiptap.flex-col .ProseMirror {
flex: 1 1 0%;
height: 100%;
min-height: 0; min-height: 0;
max-height: 100%; max-height: 100%;
overflow-y: auto; overflow-y: auto;

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

@ -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