From 3b3ceba0b7df0725077e4c31c0a3fa05db378fa5 Mon Sep 17 00:00:00 2001 From: lexflow-agent Date: Thu, 17 Sep 2026 12:54:33 +0000 Subject: [PATCH 1/2] feat: implement editor sprint 4 templates, citations and surface UX Add confirm-on-delete, measure token, drawer padding, focus traps, citation peek popover, empty-state tiles, apply-in-new-document, and picker/template fixes. Co-authored-by: Cursor (claude-sonnet-5-thinking-medium) --- frontend/src/i18n/locales/en/common.json | 15 ++ frontend/src/i18n/locales/es/common.json | 15 ++ frontend/src/pages/EditorPage.test.tsx | 13 ++ frontend/src/pages/EditorPage.tsx | 119 +++++++++++++- frontend/src/pages/editor/AiDraftPanel.tsx | 15 +- .../src/pages/editor/CitationPicker.test.tsx | 92 +++++++++++ frontend/src/pages/editor/CitationPicker.tsx | 19 ++- frontend/src/pages/editor/CommentsPanel.tsx | 21 ++- frontend/src/pages/editor/EditorToolbar.tsx | 22 ++- .../src/pages/editor/TemplatesDialog.test.tsx | 155 ++++++++++++++++++ frontend/src/pages/editor/TemplatesDialog.tsx | 88 ++++++++-- .../editor/extensions/CitationChip.test.tsx | 93 +++++++++++ .../pages/editor/extensions/CitationChip.tsx | 114 +++++++++++-- .../src/pages/editor/template-utils.test.ts | 16 ++ frontend/src/pages/editor/template-utils.ts | 27 ++- 15 files changed, 774 insertions(+), 50 deletions(-) create mode 100644 frontend/src/pages/editor/CitationPicker.test.tsx create mode 100644 frontend/src/pages/editor/TemplatesDialog.test.tsx create mode 100644 frontend/src/pages/editor/extensions/CitationChip.test.tsx diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index d144b4d0..64b7433c 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -93,6 +93,18 @@ "resultCount_one": "{{count}} result", "resultCount_other": "{{count}} results" }, + "citationChip": { + "openTitle": "{{label}} — open preview", + "popoverAria": "Citation preview for {{label}}", + "articleLabel": "Article {{num}}", + "previewError": "Could not load preview.", + "openLaw": "Open law" + }, + "emptyState": { + "template": "Template", + "ai": "AI assistant", + "citation": "Citation" + }, "templates": { "dialogAria": "Document templates", "title": "Templates", @@ -107,6 +119,9 @@ "apply": "Apply", "deleteAria": "Delete template {{name}}", "delete": "Delete", + "deleteConfirmTitle": "Delete template", + "deleteConfirmMessage": "Delete the template \"{{name}}\"? This cannot be undone.", + "applyNewDocument": "Apply in new document", "variable": "variable", "variables": "variables", "noVariables": "No variables" diff --git a/frontend/src/i18n/locales/es/common.json b/frontend/src/i18n/locales/es/common.json index c561bf46..6613f848 100644 --- a/frontend/src/i18n/locales/es/common.json +++ b/frontend/src/i18n/locales/es/common.json @@ -93,6 +93,18 @@ "resultCount_one": "{{count}} resultado", "resultCount_other": "{{count}} resultados" }, + "citationChip": { + "openTitle": "{{label}} — abrir vista previa", + "popoverAria": "Vista previa de la cita {{label}}", + "articleLabel": "Artículo {{num}}", + "previewError": "No se pudo cargar la vista previa.", + "openLaw": "Abrir ley" + }, + "emptyState": { + "template": "Plantilla", + "ai": "Asistente IA", + "citation": "Cita" + }, "templates": { "dialogAria": "Plantillas de documento", "title": "Plantillas", @@ -107,6 +119,9 @@ "apply": "Aplicar", "deleteAria": "Eliminar plantilla {{name}}", "delete": "Eliminar", + "deleteConfirmTitle": "Eliminar plantilla", + "deleteConfirmMessage": "¿Eliminar la plantilla «{{name}}»? Esta acción no se puede deshacer.", + "applyNewDocument": "Aplicar en documento nuevo", "variable": "variable", "variables": "variables", "noVariables": "Sin variables" diff --git a/frontend/src/pages/EditorPage.test.tsx b/frontend/src/pages/EditorPage.test.tsx index 8f13bbd9..fb99b44e 100644 --- a/frontend/src/pages/EditorPage.test.tsx +++ b/frontend/src/pages/EditorPage.test.tsx @@ -63,6 +63,8 @@ vi.mock('@tiptap/react', () => ({ onUpdateHandler = opts.onUpdate; return mockEditor; }, + useEditorState: ({ selector }: { selector: (snap: { editor: { isEmpty: boolean } }) => unknown }) => + selector({ editor: { isEmpty: true } }), EditorContent: () =>
, })); @@ -279,6 +281,17 @@ describe('EditorPage autosave flush', () => { expect(screen.getByText(/^Saved /)).toBeInTheDocument(); await i18n.changeLanguage('es'); }); + + it('uses measure token on the editor writing surface', () => { + renderEditor(); + expect(screen.getByTestId('editor-surface')).toHaveClass('max-w-measure'); + }); + + it('shows empty-state CTA tiles when the editor is empty', () => { + renderEditor(); + expect(screen.getByTestId('editor-empty-state')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /plantilla/i })).toBeInTheDocument(); + }); }); describe('EditorPage save indicator', () => { diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index 10d51173..77b40b44 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -33,7 +33,8 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from import { createPortal } from 'react-dom'; import { useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { useEditor, EditorContent, type Editor } from '@tiptap/react'; +import { useEditor, EditorContent, useEditorState, type Editor } from '@tiptap/react'; +import { LayoutTemplate, Scale, Sparkles } from 'lucide-react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; import { useEditorStore, makeDefaultDocument, isSentinelDocumentTitle } from '@/lib/editor-store'; @@ -156,6 +157,10 @@ function EditorDocumentSurface({ docId }: { docId: string }) { // Comments side panel (#602): open state + the comment to autofocus on open. const [commentsOpen, setCommentsOpen] = useState(false); const [focusCommentId, setFocusCommentId] = useState(null); + const citationTriggerRef = useRef(null); + const templatesTriggerRef = useRef(null); + const aiPanelTriggerRef = useRef(null); + const commentsTriggerRef = useRef(null); const addComment = useCommentStore((s) => s.addComment); const commentBadgeCount = useCommentStore((s) => openCommentCount(s.comments, docId)); @@ -338,8 +343,34 @@ function EditorDocumentSurface({ docId }: { docId: string }) { exportMarkdown(editor.getJSON(), title); }, [editor, title]); + const closeCitationPicker = useCallback(() => { + setCitationPickerOpen(false); + citationTriggerRef.current?.focus(); + }, []); + + const closeTemplatesDialog = useCallback(() => { + setTemplatesOpen(false); + templatesTriggerRef.current?.focus(); + }, []); + + const closeAiPanel = useCallback(() => { + setAiPanelOpen(false); + aiPanelTriggerRef.current?.focus(); + }, []); + + const closeCommentsPanel = useCallback(() => { + setCommentsOpen(false); + commentsTriggerRef.current?.focus(); + }, []); + + const drawerPaddingClass = cn( + 'transition-[padding] duration-200', + aiPanelOpen && 'pr-[380px]', + !aiPanelOpen && commentsOpen && 'pr-[340px]', + ); + return ( - <> +
{persistError && (
)} @@ -421,7 +456,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) { {editor && citationPickerOpen && createPortal( - setCitationPickerOpen(false)} />, + , document.body, )} @@ -429,7 +464,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) { {editor && templatesOpen && createPortal( - setTemplatesOpen(false)} />, + , document.body, )} @@ -441,7 +476,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) { editor={editor} docId={docId} docTitle={title} - onClose={() => setAiPanelOpen(false)} + onClose={closeAiPanel} />, document.body, )} @@ -454,16 +489,17 @@ function EditorDocumentSurface({ docId }: { docId: string }) { editor={editor} docId={docId} focusCommentId={focusCommentId} - onClose={() => setCommentsOpen(false)} + onClose={closeCommentsPanel} />, document.body, )} {/* TipTap content area */}
+ {editor && !isReadOnly && ( + setTemplatesOpen(true)} + onOpenAiPanel={() => setAiPanelOpen(true)} + onOpenCitationPicker={() => setCitationPickerOpen(true)} + /> + )}
- +
+ ); +} + +interface EditorEmptyStateTilesProps { + editor: Editor; + onOpenTemplates: () => void; + onOpenAiPanel: () => void; + onOpenCitationPicker: () => void; +} + +/** CTA tiles shown over an empty editor surface (#75 S4.6). */ +function EditorEmptyStateTiles({ + editor, + onOpenTemplates, + onOpenAiPanel, + onOpenCitationPicker, +}: EditorEmptyStateTilesProps) { + const { t } = useTranslation(); + const isEmpty = useEditorState({ + editor, + selector: (snap) => snap.editor.isEmpty, + }); + + if (!isEmpty) return null; + + return ( +
+
+ + + +
+
); } diff --git a/frontend/src/pages/editor/AiDraftPanel.tsx b/frontend/src/pages/editor/AiDraftPanel.tsx index 43b1138f..26840a63 100644 --- a/frontend/src/pages/editor/AiDraftPanel.tsx +++ b/frontend/src/pages/editor/AiDraftPanel.tsx @@ -16,7 +16,7 @@ * - Markdown insertion → `markdownDraftToBlocks` in `./draft-insert-utils`. * - Model selection → mirrors ChatPage (`useModels` + `useUi.defaultModel`). */ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import type { Editor } from '@tiptap/react'; import { useEditorState } from '@tiptap/react'; import { useNavigate } from 'react-router-dom'; @@ -30,6 +30,7 @@ import { isSentinelDocumentTitle } from '@/lib/editor-store'; import { EMPTY_DOC_AI_DRAFT, useAiDraftStore, type AiInsertTarget } from '@/lib/ai-draft-store'; import { useModels } from '@/lib/queries'; import { useUi } from '@/lib/store'; +import { useFocusTrap } from '@/lib/useFocusTrap'; import type { ChatSource } from '@/lib/types'; import { citationFromSource, type CitationAttrs } from './citation-utils'; import { isInsertTargetValid, markdownDraftToBlocks } from './draft-insert-utils'; @@ -84,6 +85,9 @@ function captureSelectionTarget(editor: Editor): AiInsertTarget | null { export function AiDraftPanel({ editor, docId, docTitle, onClose }: AiDraftPanelProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const panelRef = useRef(null); + const closeButtonRef = useRef(null); + const promptRef = useRef(null); const { data: models = [] } = useModels(); const defaultModel = useUi((s) => s.defaultModel); const model = useMemo(() => { @@ -231,8 +235,15 @@ export function AiDraftPanel({ editor, docId, docTitle, onClose }: AiDraftPanelP const citableCount = sources.filter((s) => s.target?.lawId).length; + useFocusTrap(panelRef, true); + + useEffect(() => { + requestAnimationFrame(() => promptRef.current?.focus()); + }, []); + return (