From e3bc877f5bd5e2f48781807a6be6ed35fa76ae04 Mon Sep 17 00:00:00 2001 From: Rolf Smit Date: Fri, 23 Oct 2026 10:57:51 +0200 Subject: [PATCH 1/3] feat: add a preview to the quote composer --- KNOWLEDGE_BASE.md | 2 +- .../js/components/commit-quote-button.tsx | 97 +++++++++++++------ tests/Unit/QuoteDraft.test.tsx | 9 ++ 3 files changed, 76 insertions(+), 32 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 391078bda..3eb025152 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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-quote-button.tsx b/resources/js/components/commit-quote-button.tsx index a4d8804ad..162ba6102 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) && @@ -224,31 +239,45 @@ function QuoteComposer({ - +
+ + {preview.isPreviewing && ( + + )} +
- + {!preview.isPreviewing && ( + + )} + {isRestored && ( { diff --git a/tests/Unit/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 195d9a928..355f1c3d8 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 } = From 1110cc1e49f4bc93d88d55d64acdd682df40eb3c Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 01:17:38 +0200 Subject: [PATCH 2/3] fix: keep composer dialogs open when leaving the preview with Esc --- KNOWLEDGE_BASE.md | 2 +- .../js/components/commit-composer-dialog.tsx | 4 +- .../js/components/commit-quote-button.tsx | 6 ++- resources/js/components/composer-preview.tsx | 1 + tests/Unit/QuoteDraft.test.tsx | 49 +++++++++++++++++++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 3eb025152..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. 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 162ba6102..5646e4e85 100644 --- a/resources/js/components/commit-quote-button.tsx +++ b/resources/js/components/commit-quote-button.tsx @@ -167,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(); } }} diff --git a/resources/js/components/composer-preview.tsx b/resources/js/components/composer-preview.tsx index b0646a9de..8324677dd 100644 --- a/resources/js/components/composer-preview.tsx +++ b/resources/js/components/composer-preview.tsx @@ -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/QuoteDraft.test.tsx b/tests/Unit/QuoteDraft.test.tsx index 355f1c3d8..632987e54 100644 --- a/tests/Unit/QuoteDraft.test.tsx +++ b/tests/Unit/QuoteDraft.test.tsx @@ -188,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(); + } +}); From d98949f468e17487f3d163b5308362b5b86ba967 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 24 Oct 2026 01:28:40 +0200 Subject: [PATCH 3/3] fix: move focus into the preview when it is opened by click --- resources/js/components/composer-preview.tsx | 2 +- tests/Unit/ComposerPreview.test.tsx | 37 +++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/resources/js/components/composer-preview.tsx b/resources/js/components/composer-preview.tsx index 8324677dd..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" 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(); + } +});