Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions frontend/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions frontend/src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/pages/EditorPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => <div data-testid="editor-content" />,
}));

Expand Down Expand Up @@ -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', () => {
Expand Down
119 changes: 110 additions & 9 deletions frontend/src/pages/EditorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean>(false);
const [focusCommentId, setFocusCommentId] = useState<string | null>(null);
const citationTriggerRef = useRef<HTMLButtonElement>(null);
const templatesTriggerRef = useRef<HTMLButtonElement>(null);
const aiPanelTriggerRef = useRef<HTMLButtonElement>(null);
const commentsTriggerRef = useRef<HTMLButtonElement>(null);
const addComment = useCommentStore((s) => s.addComment);
const commentBadgeCount = useCommentStore((s) => openCommentCount(s.comments, docId));

Expand Down Expand Up @@ -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 (
<>
<div className={drawerPaddingClass}>
{persistError && (
<div
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[12.5px] text-amber-900 dark:text-amber-100"
Expand Down Expand Up @@ -413,6 +444,10 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
setCommentsOpen(true);
}}
commentBadgeCount={commentBadgeCount}
citationButtonRef={citationTriggerRef}
templatesButtonRef={templatesTriggerRef}
aiPanelButtonRef={aiPanelTriggerRef}
commentsButtonRef={commentsTriggerRef}
/>
)}

Expand All @@ -421,15 +456,15 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
{editor &&
citationPickerOpen &&
createPortal(
<CitationPicker editor={editor} onClose={() => setCitationPickerOpen(false)} />,
<CitationPicker editor={editor} onClose={closeCitationPicker} />,
document.body,
)}

{/* Template library (#600) — portaled to <body> for the same reason. */}
{editor &&
templatesOpen &&
createPortal(
<TemplatesDialog editor={editor} onClose={() => setTemplatesOpen(false)} />,
<TemplatesDialog editor={editor} onClose={closeTemplatesDialog} />,
document.body,
)}

Expand All @@ -441,7 +476,7 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
editor={editor}
docId={docId}
docTitle={title}
onClose={() => setAiPanelOpen(false)}
onClose={closeAiPanel}
/>,
document.body,
)}
Expand All @@ -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 */}
<div
data-testid="editor-surface"
className={cn(
'min-h-[60vh] rounded-lg border border-border bg-surface p-6',
'prose prose-neutral dark:prose-invert max-w-none',
'relative min-h-[60vh] rounded-lg border border-border bg-surface p-6',
'prose prose-neutral dark:prose-invert max-w-measure',
// Headings inherit the app's display font + tight tracking instead
// of the plugin's default body font — matches every other heading
// in the app (`font-display ... tracking-tight`). Sizes/weights per
Expand Down Expand Up @@ -505,8 +541,73 @@ function EditorDocumentSurface({ docId }: { docId: string }) {
isReadOnly && 'cursor-default',
)}
>
{editor && !isReadOnly && (
<EditorEmptyStateTiles
editor={editor}
onOpenTemplates={() => setTemplatesOpen(true)}
onOpenAiPanel={() => setAiPanelOpen(true)}
onOpenCitationPicker={() => setCitationPickerOpen(true)}
/>
)}
<EditorContent editor={editor} />
</div>
</>
</div>
);
}

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 (
<div
className="pointer-events-none absolute inset-6 z-10 flex items-start pt-12"
data-testid="editor-empty-state"
>
<div className="pointer-events-auto flex flex-wrap gap-2">
<button
type="button"
onClick={onOpenTemplates}
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-[13px] font-medium shadow-sm transition-colors hover:bg-surface-2"
>
<LayoutTemplate className="size-4 text-indigo-600" aria-hidden />
{t('editor.emptyState.template')}
</button>
<button
type="button"
onClick={onOpenAiPanel}
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-[13px] font-medium shadow-sm transition-colors hover:bg-surface-2"
>
<Sparkles className="size-4 text-indigo-600" aria-hidden />
{t('editor.emptyState.ai')}
</button>
<button
type="button"
onClick={onOpenCitationPicker}
className="flex items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-[13px] font-medium shadow-sm transition-colors hover:bg-surface-2"
>
<Scale className="size-4 text-indigo-600" aria-hidden />
{t('editor.emptyState.citation')}
</button>
</div>
</div>
);
}
15 changes: 14 additions & 1 deletion frontend/src/pages/editor/AiDraftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<HTMLElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const promptRef = useRef<HTMLTextAreaElement>(null);
const { data: models = [] } = useModels();
const defaultModel = useUi((s) => s.defaultModel);
const model = useMemo(() => {
Expand Down Expand Up @@ -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 (
<aside
ref={panelRef}
role="complementary"
aria-label={t('editor.aiDraft.panelAria')}
className="fixed inset-y-0 right-0 z-40 flex w-[380px] max-w-[92vw] flex-col border-l border-border bg-surface shadow-xl"
Expand All @@ -241,6 +252,7 @@ export function AiDraftPanel({ editor, docId, docTitle, onClose }: AiDraftPanelP
<Sparkles className="size-4 text-indigo-600" />
<span className="flex-1 text-[14px] font-semibold">{t('editor.aiDraft.title')}</span>
<Button
ref={closeButtonRef}
variant="ghost"
size="icon-sm"
aria-label={t('editor.aiDraft.closeAria')}
Expand Down Expand Up @@ -290,6 +302,7 @@ export function AiDraftPanel({ editor, docId, docTitle, onClose }: AiDraftPanelP
<section className="space-y-2">
<div className="label-caps">{t('editor.aiDraft.promptSection')}</div>
<textarea
ref={promptRef}
value={prompt}
onChange={(e) => setPrompt(docId, e.target.value)}
onKeyDown={onPromptKeyDown}
Expand Down
Loading
Loading