Ctrl K
ComunidadGuías…

components/user-text-section.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
'use client'

import React, { useCallback, useEffect, useRef, useState } from 'react'
import TextareaAutosize from 'react-textarea-autosize'

import {
  IconCheck as Check,
  IconChevronDown as ChevronDown,
  IconChevronUp as ChevronUp,
  IconCopy as Copy,
  IconPencil as Pencil
} from '@tabler/icons-react'

import { cn } from '@/lib/utils'
import { stripMarkdownText } from '@/lib/utils/markdown'

import { Button } from './ui/button'
import { CollapsibleMessage } from './collapsible-message'
import { PastedContentCard, UrlChip } from './pasted-parts'

// Legacy: old messages carry pasted material wrapped in <user-content> tags
// (new messages use `data-pastedContent` parts). Split it out so we can render
// it as a collapsed card and keep the instruction as the prominent text.
const PASTED_RE = /<user-content>\n?([\s\S]*?)\n?<\/user-content>/g

function splitPastedContent(content: string): {
  cards: string[]
  rest: string
} {
  const cards: string[] = []
  const rest = content
    .replace(PASTED_RE, (_, body: string) => {
      cards.push(body)
      return ''
    })
    .trim()
  return { cards, rest }
}

interface UserTextSectionProps {
  content: string
  // Pasted attachments (new data parts), placed below the instruction.
  pastedTexts?: string[]
  quotedContexts?: string[]
  noteContexts?: { title?: string; text: string }[]
  urls?: string[]
  messageId?: string
  onUpdateMessage?: (messageId: string, newContent: string) => Promise<void>
}

export const UserTextSection: React.FC<UserTextSectionProps> = ({
  content,
  pastedTexts = [],
  quotedContexts = [],
  noteContexts = [],
  urls = [],
  messageId,
  onUpdateMessage
}) => {
  // Legacy cards live inside the text; new pasted texts arrive as props.
  const { cards: legacyCards, rest } = splitPastedContent(content)
  const cards = [...legacyCards, ...pastedTexts]
  const [isEditing, setIsEditing] = useState(false)
  const [editedContent, setEditedContent] = useState(rest)
  const [isComposing, setIsComposing] = useState(false)
  const [enterDisabled, setEnterDisabled] = useState(false)
  const [copied, setCopied] = useState(false)
  const [isExpanded, setIsExpanded] = useState(false)
  const [isClamped, setIsClamped] = useState(false)
  const enterResetTimeoutRef = useRef<NodeJS.Timeout | null>(null)

  const contentRef = useCallback((node: HTMLDivElement | null) => {
    if (node) {
      setIsClamped(node.scrollHeight > node.clientHeight)
    }
  }, [])

  useEffect(() => {
    return () => {
      if (enterResetTimeoutRef.current) {
        clearTimeout(enterResetTimeoutRef.current)
      }
    }
  }, [])

  const handleCopyClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
    e.stopPropagation()
    try {
      await navigator.clipboard.writeText(content)
      setCopied(true)
      setTimeout(() => setCopied(false), 2000)
    } catch {
      // Clipboard access denied — silently ignore
    }
  }

  const handleEditClick = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.stopPropagation()
    setEditedContent(rest)
    setIsEditing(true)
  }

  const handleCancelClick = () => {
    setIsEditing(false)
  }

  const handleSaveClick = async () => {
    if (!onUpdateMessage || !messageId) return

    setIsEditing(false)

    // Re-wrap only the legacy text-embedded cards; new pasted texts are
    // separate parts and are not part of the editable text.
    const wrapped = [
      ...legacyCards.map(c => `<user-content>\n${c}\n</user-content>`),
      editedContent
    ]
      .filter(s => s && s.trim())
      .join('\n\n')

    try {
      await onUpdateMessage(messageId, wrapped)
    } catch (error) {
      console.error('Failed to save message:', error)
    }
  }

  const handleTextareaKeyDown = (
    event: React.KeyboardEvent<HTMLTextAreaElement>
  ) => {
    if (event.key !== 'Enter') {
      return
    }

    // Any modifier (Shift / Alt / Meta / Ctrl) + Enter → let it insert a
    // newline instead of submitting the edit.
    // nativeEvent.isComposing catches the IME candidate-confirm Enter even
    // after React-level isComposing has flipped.
    if (
      event.shiftKey ||
      event.altKey ||
      event.metaKey ||
      event.ctrlKey ||
      isComposing ||
      (event.nativeEvent as KeyboardEvent).isComposing ||
      enterDisabled
    ) {
      // Alt+Enter on macOS does not insert \n by default; do it manually.
      if (event.altKey && !event.shiftKey && !event.metaKey && !event.ctrlKey) {
        event.preventDefault()
        const textarea = event.target as HTMLTextAreaElement
        const start = textarea.selectionStart ?? editedContent.length
        const end = textarea.selectionEnd ?? editedContent.length
        const next =
          editedContent.slice(0, start) + '\n' + editedContent.slice(end)
        setEditedContent(next)
        requestAnimationFrame(() => {
          textarea.selectionStart = textarea.selectionEnd = start + 1
        })
      }
      return
    }

    event.preventDefault()
    void handleSaveClick()
  }

  const handleCompositionStart = () => {
    setIsComposing(true)
  }

  const handleCompositionEnd = () => {
    setIsComposing(false)
    setEnterDisabled(true)
    if (enterResetTimeoutRef.current) {
      clearTimeout(enterResetTimeoutRef.current)
    }
    enterResetTimeoutRef.current = setTimeout(() => {
      setEnterDisabled(false)
      enterResetTimeoutRef.current = null
    }, 50)
  }

  // Pasted attachments (cards + URL chips), placed below the instruction.
  const attachments = (cards.length > 0 ||
    quotedContexts.length > 0 ||
    noteContexts.length > 0 ||
    urls.length > 0) && (
    <div className="mt-2 flex flex-col items-start gap-1.5">
      {cards.map((c, i) => (
        <PastedContentCard key={`card-${i}`} text={c} />
      ))}
      {quotedContexts.map((c, i) => (
        <PastedContentCard
          key={`quoted-context-${i}`}
          text={c}
          label="Quoted context"
        />
      ))}
      {noteContexts.map((note, i) => (
        <PastedContentCard
          key={`note-context-${i}`}
          text={stripMarkdownText(note.text)}
          label={
            note.title
              ? `Note: ${stripMarkdownText(note.title) || 'Untitled note'}`
              : 'Note'
          }
        />
      ))}
      {urls.map((u, i) => (
        <UrlChip key={`url-${i}`} url={u} />
      ))}
    </div>
  )

  return (
    <CollapsibleMessage role="user">
      <div
        className="flex-1 break-words w-full group outline-hidden relative"
        tabIndex={0}
      >
        {isEditing ? (
          <div className="flex flex-col gap-2">
            <TextareaAutosize
              value={editedContent}
              onChange={e => setEditedContent(e.target.value)}
              autoFocus
              onCompositionStart={handleCompositionStart}
              onCompositionEnd={handleCompositionEnd}
              onKeyDown={handleTextareaKeyDown}
              className="resize-none flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50"
              minRows={2}
              maxRows={10}
            />
            {attachments}
            <div className="flex justify-end gap-2">
              <Button variant="secondary" size="sm" onClick={handleCancelClick}>
                Cancel
              </Button>
              <Button size="sm" onClick={handleSaveClick}>
                Save
              </Button>
            </div>
          </div>
        ) : (
          <div className="relative">
            <div
              ref={contentRef}
              className={cn(
                'whitespace-pre-wrap',
                !isExpanded && 'line-clamp-3'
              )}
            >
              {rest}
            </div>
            {(isClamped || isExpanded) && (
              <button
                type="button"
                className="text-xs text-muted-foreground hover:text-foreground mt-1"
                onClick={() => setIsExpanded(prev => !prev)}
              >
                {isExpanded ? (
                  <span className="inline-flex items-center gap-0.5">
                    Show less <ChevronUp className="size-3" />
                  </span>
                ) : (
                  <span className="inline-flex items-center gap-0.5">
                    Show more <ChevronDown className="size-3" />
                  </span>
                )}
              </button>
            )}
            {attachments}
            <div
              className={cn(
                'absolute -top-1 -right-1 flex items-center gap-0.5 p-0.5 transition-opacity bg-background rounded-full shadow-sm border',
                'opacity-0',
                'max-md:group-focus-within:opacity-100',
                'md:group-hover:opacity-100'
              )}
            >
              <Button
                variant="ghost"
                size="icon"
                className="rounded-full size-7"
                onMouseDown={e => e.preventDefault()}
                onClick={handleCopyClick}
              >
                {copied ? (
                  <Check className="size-3.5" />
                ) : (
                  <Copy className="size-3.5" />
                )}
              </Button>
              <Button
                variant="ghost"
                size="icon"
                className="rounded-full size-7"
                onMouseDown={e => e.preventDefault()}
                onClick={handleEditClick}
              >
                <Pencil className="size-3.5" />
              </Button>
            </div>
          </div>
        )}
      </div>
    </CollapsibleMessage>
  )
}