diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 391078bda..e32994e2b 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -209,7 +209,7 @@ Sources: [body renderer](resources/js/components/commit-body.tsx), [code blocks] ### Draft previews -Use the composer's eye button or **⌘/Ctrl+Shift+P** to preview a commit or reply before submitting it. Preview shows the rendered code, emoji, mentions, links, and images, and identifies the link selected for a card. Esc returns from preview to writing. +Use the composer's eye button or **⌘/Ctrl+Shift+P** to preview a commit, reply, or quote before submitting it. Preview shows the rendered code, emoji, mentions, links, and images, and identifies the link selected for a card. Esc returns from preview to writing. Draft text is not scanned for secrets before publishing or saving locally. @@ -251,7 +251,7 @@ Sources: [reply validation](app/Http/Requests/CreateCommitReplyRequest.php), [co ### Quoting commits -Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has no preview, link-card choice, poll, or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes. +Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has a preview but no link-card choice, poll, or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes. The embedded original only shows content the viewer may see. Deleted originals appear as `[deleted]`; otherwise-inaccessible originals appear as `[unavailable]` without exposing their author or body. There is no comment-free repost action. diff --git a/resources/js/components/commit-composer-dialog.tsx b/resources/js/components/commit-composer-dialog.tsx index 025490e3d..dd8c44a7b 100644 --- a/resources/js/components/commit-composer-dialog.tsx +++ b/resources/js/components/commit-composer-dialog.tsx @@ -72,7 +72,9 @@ export function CommitComposerDialog({ if ( document.querySelector( '[data-mention-suggestions], [data-command-suggestions]', - ) + ) || + (event.target instanceof Element && + event.target.closest('[data-composer-preview]')) ) { event.preventDefault(); } diff --git a/resources/js/components/commit-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index a4d8804ad..5646e4e85 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -12,7 +12,13 @@ import { imageErrorFrom, } from '@/components/commit-image-picker'; import { CommitRulesDialog, useCommitRules } from '@/components/commit-rules'; +import { + ComposerPreviewBody, + PreviewToggle, + useComposerPreview, +} from '@/components/composer-preview'; import InputError from '@/components/input-error'; +import { typedLinks } from '@/components/linkified-text'; import { MentionTextarea } from '@/components/mention-textarea'; import { QuotedCommit } from '@/components/quoted-commit'; import { StashLabel } from '@/components/stash-label'; @@ -136,8 +142,17 @@ function QuoteComposer({ const isQuotable = (images.length > 0 || characterCount(body.trim()) >= minBodyLength) && bodyCharacterCount(body) <= maxBodyLength; + const preview = useComposerPreview({ + body, + textareaRef, + isSubmittable: isQuotable, + }); const submitOnModEnter = (event: KeyboardEvent) => { + if (preview.handleShortcut(event)) { + return; + } + if ( event.key === 'Enter' && hasModKey(event) && @@ -152,7 +167,11 @@ function QuoteComposer({ return ( { - if (document.querySelector('[data-mention-suggestions]')) { + if ( + document.querySelector('[data-mention-suggestions]') || + (event.target instanceof Element && + event.target.closest('[data-composer-preview]')) + ) { event.preventDefault(); } }} @@ -224,31 +243,45 @@ function QuoteComposer({ - +
+ + {preview.isPreviewing && ( + + )} +
- + {!preview.isPreviewing && ( + + )} + {isRestored && ( { diff --git a/resources/js/components/composer-preview.tsx b/resources/js/components/composer-preview.tsx index b0646a9de..ee8065f88 100644 --- a/resources/js/components/composer-preview.tsx +++ b/resources/js/components/composer-preview.tsx @@ -152,7 +152,7 @@ export function PreviewToggle({ type="button" variant="ghost" size="icon" - onClick={() => preview.toggle()} + onClick={() => preview.toggle(true)} aria-pressed={preview.isPreviewing} aria-label="Preview" aria-keyshortcuts="Meta+Shift+P Control+Shift+P" @@ -189,6 +189,7 @@ export function ComposerPreviewBody({ aria-label="Preview" onKeyDown={preview.onPreviewKeyDown} className={cn('min-w-0 outline-none', className)} + data-composer-preview data-test="composer-preview" > {preview.segments.length > 0 ? ( diff --git a/tests/Unit/ComposerPreview.test.tsx b/tests/Unit/ComposerPreview.test.tsx index c23f9b3ca..1b68e29f3 100644 --- a/tests/Unit/ComposerPreview.test.tsx +++ b/tests/Unit/ComposerPreview.test.tsx @@ -3,13 +3,14 @@ import assert from 'node:assert/strict'; import { App } from '@inertiajs/react'; import type { ComponentProps, KeyboardEvent, ReactElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { test } from 'vite-plus/test'; +import { test, vi } from 'vite-plus/test'; import { CommitBody } from '../../resources/js/components/commit-body'; import { CommitComposer } from '../../resources/js/components/commit-composer'; import { revokeObjectUrls } from '../../resources/js/components/commit-image-picker'; import { ComposerPollPreview, ComposerPreviewBody, + PreviewToggle, useComposerPreview, type ComposerPreview, } from '../../resources/js/components/composer-preview'; @@ -459,3 +460,37 @@ for (const pasteisMode of [false, true]) { }); } } + +test('clicking Preview moves focus into the preview, where Escape returns to writing', () => { + const preview = previewFor({ isSubmittable: true }); + const focus = vi.fn(); + const getElementById = vi.fn(() => ({ focus })); + vi.stubGlobal('requestAnimationFrame', (callback: () => void) => + callback(), + ); + vi.stubGlobal('document', { getElementById }); + + try { + const toggles: ReactElement<{ + children: ReactElement<{ onClick: () => void }>; + }>[] = []; + + function Toggle() { + toggles.push( + PreviewToggle({ preview }) as (typeof toggles)[number], + ); + + return null; + } + + renderToStaticMarkup(); + const tooltip = toggles[0]; + assert.ok(tooltip); + tooltip.props.children.props.onClick(); + + assert.deepEqual(getElementById.mock.calls, [[preview.previewId]]); + assert.equal(focus.mock.calls.length, 1); + } finally { + vi.unstubAllGlobals(); + } +}); diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 195d9a928..632987e54 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -44,6 +44,15 @@ vi.doMock('../../resources/js/hooks/use-stash', () => ({ useHasStash: () => true, moveCaretToEnd: vi.fn(), })); +vi.doMock('../../resources/js/components/composer-preview', async () => ({ + ...(await vi.importActual( + '../../resources/js/components/composer-preview', + )), + useComposerPreview: () => ({ + isPreviewing: false, + handleShortcut: () => false, + }), +})); const { Form } = await import('@inertiajs/react'); const { CommitQuoteButton } = @@ -179,3 +188,52 @@ test('quotes submit restored images and clear the complete draft only on success vi.unstubAllGlobals(); } }); + +test('escape in the preview returns to writing without closing the quote', () => { + const commit = { + id: 'quoted', + user_id: 'author', + user: { username: 'author' }, + status: 'approved', + } as Commit; + const button = CommitQuoteButton({ commit })!; + const dialog = React.Children.toArray( + button.props.children, + )[1] as ReactElement<{ + children: ReactElement<{ commit: Commit; onClose: () => void }>; + }>; + const composer = dialog.props.children; + const renderComposer = composer.type as ( + props: typeof composer.props, + ) => ReactElement<{ onEscapeKeyDown: (event: unknown) => void }>; + const { onEscapeKeyDown } = renderComposer(composer.props).props; + + class FakeElement { + constructor(private readonly isInPreview: boolean) {} + + closest(selector: string) { + return this.isInPreview && selector === '[data-composer-preview]' + ? this + : null; + } + } + + vi.stubGlobal('Element', FakeElement); + vi.stubGlobal('document', { querySelector: () => null }); + try { + const escapeFrom = (isInPreview: boolean) => { + const preventDefault = vi.fn(); + onEscapeKeyDown({ + target: new FakeElement(isInPreview), + preventDefault, + }); + + return preventDefault.mock.calls.length; + }; + + assert.equal(escapeFrom(true), 1); + assert.equal(escapeFrom(false), 0); + } finally { + vi.unstubAllGlobals(); + } +});