[aidan] ux/large-text-paste: make pasted-text cards editable with autosave

This commit is contained in:
abccodes
2026-07-02 02:45:52 -07:00
parent ab982afcea
commit 2b806242f2
5 changed files with 84 additions and 26 deletions
@@ -91,6 +91,11 @@ function formatPasteLabel(charCount: number): string {
return `Pasted text (${charCount.toLocaleString()} chars)`;
}
export function updatePasteCardLabel(card: HTMLElement, charCount: number): void {
const label = card.firstElementChild as HTMLElement | null;
if (label) label.textContent = formatPasteLabel(charCount);
}
export function createPasteCardElement(
pasteId: string,
charCount: number,
@@ -122,7 +127,13 @@ export function createPasteCardElement(
const label = document.createElement('span');
label.textContent = formatPasteLabel(charCount);
Object.assign(label.style, { maxWidth: '240px', overflow: 'hidden', textOverflow: 'ellipsis' });
Object.assign(label.style, {
maxWidth: '240px',
overflow: 'hidden',
textOverflow: 'ellipsis',
opacity: '0.85',
transition: 'transform 0.15s ease, opacity 0.15s ease',
});
label.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); onExpand(pasteId); });
const closeBtn = document.createElement('span');
@@ -145,6 +156,9 @@ export function createPasteCardElement(
closeBtn.addEventListener('mouseout', () => { closeBtn.style.opacity = '0.6'; closeBtn.style.color = 'inherit'; });
closeBtn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); onRemove(pasteId); });
card.addEventListener('mouseenter', () => { label.style.transform = 'translateX(2px)'; label.style.opacity = '1'; });
card.addEventListener('mouseleave', () => { label.style.transform = 'translateX(0)'; label.style.opacity = '0.85'; });
card.appendChild(label);
card.appendChild(closeBtn);
return card;
@@ -107,8 +107,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
};
}, [prefillPrompt]);
useDraftLoad(editorRef, ownerId);
const [hasContent, setHasContent] = useState(() => !!loadDraft(ownerId));
const [attachedSkills, setAttachedSkills] = useState<Record<string, AttachedSkill>>({});
const [previewPasteId, setPreviewPasteId] = useState<string | null>(null);
@@ -292,6 +290,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
isDragOver,
handleInput, handleEditorClick, handlePickerSelect, handleKeyDown, handlePaste,
handleDragOver, handleDragLeave, handleDrop,
removePasteCard, savePasteCard,
} = useEditorHandlers({
editorRef, generalFileInputRef, ownerId, sessionId, autoRunMode, c, skills,
elementSelection, setHasContent, setAttachedSkills, setForcedTools, onModeChange,
@@ -299,6 +298,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
onPasteExpand: setPreviewPasteId,
});
useDraftLoad(editorRef, ownerId, setPreviewPasteId, removePasteCard, c.font.mono, c.status.error);
const currentMode = modesMap[mode];
const FALLBACK_MODE = { ...FALLBACK_MODE_BASE, color: c.accent.primary };
const modeConf = currentMode
@@ -310,7 +311,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
return (
<>
<PastePreviewDialog pasteId={previewPasteId} onClose={() => setPreviewPasteId(null)} />
<PastePreviewDialog pasteId={previewPasteId} onClose={() => setPreviewPasteId(null)} onSave={savePasteCard} />
<ChatInputView
c={c}
containerRef={containerRef}
@@ -1,5 +1,5 @@
import { useEffect, RefObject } from 'react';
import { PASTE_CARD_ATTR, getPasteContent } from '@/app/components/editor/richEditorUtils';
import { PASTE_CARD_ATTR, getPasteContent, createPasteCardElement } from '@/app/components/editor/richEditorUtils';
// Module-level draft store keyed by sessionId; survives unmount/remount and preserves skill pills via innerHTML.
const _draftStore = new Map<string, string>();
@@ -26,7 +26,14 @@ export function deleteDraft(ownerId: string) {
_draftStore.delete(ownerId);
}
export function useDraftLoad(editorRef: RefObject<HTMLDivElement>, ownerId: string) {
export function useDraftLoad(
editorRef: RefObject<HTMLDivElement>,
ownerId: string,
onPasteExpand: (id: string) => void,
onPasteRemove: (id: string) => void,
monoFont: string,
errorColor: string,
) {
useEffect(() => {
const saved = _draftStore.get(ownerId);
const editor = editorRef.current;
@@ -41,10 +48,17 @@ export function useDraftLoad(editorRef: RefObject<HTMLDivElement>, ownerId: stri
}
if (!editor.textContent?.trim()) {
editor.innerHTML = saved;
// innerHTML restore drops JS listeners, so stale paste cards are rebuilt fresh below instead of just kept.
const staleCards = editor.querySelectorAll(`[${PASTE_CARD_ATTR}]`);
staleCards.forEach((el) => {
const pid = el.getAttribute(PASTE_CARD_ATTR);
if (!pid || !getPasteContent(pid)) el.remove();
const content = pid ? getPasteContent(pid) : undefined;
if (!pid || content === undefined) {
el.remove();
return;
}
const fresh = createPasteCardElement(pid, content.length, onPasteExpand, onPasteRemove, monoFont, errorColor);
el.replaceWith(fresh);
});
const range = document.createRange();
range.selectNodeContents(editor);
@@ -11,6 +11,7 @@ import {
createPasteId,
setPasteContent,
deletePasteContent,
updatePasteCardLabel,
detectEditorTrigger,
TriggerState,
EMPTY_TRIGGER,
@@ -112,6 +113,15 @@ export function useEditorHandlers(p: Params) {
editor.focus();
}, [updateHasContent]);
const savePasteCard = useCallback((pasteId: string, text: string) => {
setPasteContent(pasteId, text);
const editor = editorRef.current;
if (!editor) return;
const card = editor.querySelector(`[${PASTE_CARD_ATTR}="${pasteId}"]`) as HTMLElement | null;
if (card) updatePasteCardLabel(card, text.length);
scheduleDraftSave(ownerId, () => readEditorHTML(editor));
}, [ownerId]);
const removeSkillPill = useCallback((skillId: string) => {
const editor = editorRef.current;
if (!editor) return;
@@ -349,5 +359,6 @@ export function useEditorHandlers(p: Params) {
updateHasContent,
handleInput, handleEditorClick, handlePickerSelect, handleKeyDown, handlePaste,
handleDragOver, handleDragLeave, handleDrop,
removePasteCard, savePasteCard,
};
}
@@ -1,20 +1,31 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { getPasteContent } from '@/app/components/editor/richEditorUtils';
interface Props {
pasteId: string | null;
onClose: () => void;
onSave: (pasteId: string, text: string) => void;
}
export const PastePreviewDialog: React.FC<Props> = ({ pasteId, onClose }) => {
export const PastePreviewDialog: React.FC<Props> = ({ pasteId, onClose, onSave }) => {
const c = useClaudeTokens();
const open = !!pasteId;
const content = pasteId ? (getPasteContent(pasteId) ?? '') : '';
const chars = content.length;
const original = pasteId ? getPasteContent(pasteId) : undefined;
const [draft, setDraft] = useState('');
useEffect(() => {
setDraft(original ?? '');
}, [pasteId]);
const handleChange = (text: string) => {
setDraft(text);
if (pasteId) onSave(pasteId, text);
};
return (
<Dialog
@@ -27,26 +38,33 @@ export const PastePreviewDialog: React.FC<Props> = ({ pasteId, onClose }) => {
Pasted text
</Typography>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.75rem', mt: 0.25 }}>
{chars.toLocaleString()} characters
{draft.length.toLocaleString()} characters
</Typography>
</Box>
<Box
sx={{
p: 2,
maxHeight: '60vh',
overflowY: 'auto',
fontFamily: c.font.mono,
fontSize: '0.78rem',
color: c.text.primary,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
bgcolor: c.bg.surface,
}}
>
{content || (
<Box sx={{ p: 2, bgcolor: c.bg.surface }}>
{original === undefined ? (
<Typography sx={{ color: c.text.tertiary, fontSize: '0.85rem', fontStyle: 'italic' }}>
This pasted text is no longer available. Re-paste to restore it.
</Typography>
) : (
<TextField
value={draft}
onChange={(e) => handleChange(e.target.value)}
multiline
fullWidth
minRows={6}
maxRows={20}
autoFocus
sx={{
'& .MuiInputBase-root': {
fontFamily: c.font.mono,
fontSize: '0.78rem',
color: c.text.primary,
alignItems: 'flex-start',
},
'& .MuiOutlinedInput-notchedOutline': { borderColor: c.border.subtle },
}}
/>
)}
</Box>
</Dialog>