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
4 changes: 2 additions & 2 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion resources/js/components/commit-composer-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
103 changes: 71 additions & 32 deletions resources/js/components/commit-quote-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<HTMLTextAreaElement>) => {
if (preview.handleShortcut(event)) {
return;
}

if (
event.key === 'Enter' &&
hasModKey(event) &&
Expand All @@ -152,7 +167,11 @@ function QuoteComposer({
return (
<DialogContent
onEscapeKeyDown={(event) => {
if (document.querySelector('[data-mention-suggestions]')) {
if (
document.querySelector('[data-mention-suggestions]') ||
(event.target instanceof Element &&
event.target.closest('[data-composer-preview]'))
) {
event.preventDefault();
}
}}
Expand Down Expand Up @@ -224,31 +243,45 @@ function QuoteComposer({
<label htmlFor={id} className="sr-only">
Your commit
</label>
<MentionTextarea
ref={textareaRef}
id={id}
quote={commit.id}
name="body"
rows={1}
autoFocus
value={body}
onValueChange={updateBody}
onKeyDown={submitOnModEnter}
onPaste={attachments.onPaste}
onFocus={
isRestored ? moveCaretToEnd : undefined
}
maxLength={maxRawBodyLength}
placeholder="Add your take"
autoComplete="off"
aria-invalid={
errors.body || errors.quoted_commit_id
? true
: undefined
}
aria-describedby={`${id}-count`}
className="block field-sizing-content max-h-[40dvh] min-h-10 w-full resize-none overflow-x-hidden overflow-y-auto bg-transparent py-1.5 text-[17px] leading-relaxed outline-none placeholder:text-muted-foreground focus-visible:outline-none"
/>
<div className="grid">
<MentionTextarea
ref={textareaRef}
id={id}
quote={commit.id}
name="body"
rows={1}
autoFocus
value={body}
onValueChange={updateBody}
onKeyDown={submitOnModEnter}
onPaste={attachments.onPaste}
onFocus={
isRestored ? moveCaretToEnd : undefined
}
maxLength={maxRawBodyLength}
placeholder="Add your take"
autoComplete="off"
aria-invalid={
errors.body || errors.quoted_commit_id
? true
: undefined
}
aria-describedby={`${id}-count`}
className={cn(
'col-start-1 row-start-1 block field-sizing-content max-h-[40dvh] min-h-10 w-full resize-none overflow-x-hidden overflow-y-auto bg-transparent py-1.5 text-[17px] leading-relaxed outline-none placeholder:text-muted-foreground focus-visible:outline-none',
{ invisible: preview.isPreviewing },
)}
/>
{preview.isPreviewing && (
<ComposerPreviewBody
preview={preview}
url={typedLinks(body)[0] ?? null}
images={attachments.images}
descriptions={attachments.descriptions}
className="col-start-1 row-start-1 py-1.5"
/>
)}
</div>

<InputError
message={errors.body ?? errors.quoted_commit_id}
Expand All @@ -259,12 +292,14 @@ function QuoteComposer({
textarea={textareaRef}
/>

<ImagePickerPreviews
images={images}
descriptions={descriptions}
onDescribe={attachments.describe}
onRemove={attachments.remove}
/>
{!preview.isPreviewing && (
<ImagePickerPreviews
images={images}
descriptions={descriptions}
onDescribe={attachments.describe}
onRemove={attachments.remove}
/>
)}
<InputError
message={
attachments.error ?? imageErrorFrom(errors)
Expand All @@ -283,6 +318,10 @@ function QuoteComposer({
onPick={attachments.pick}
className="size-8"
/>
<PreviewToggle
preview={preview}
className="-ml-2 size-8"
/>
{isRestored && (
<StashLabel
onDrop={() => {
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/composer-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 ? (
Expand Down
37 changes: 36 additions & 1 deletion tests/Unit/ComposerPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(<Toggle />);
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();
}
});
58 changes: 58 additions & 0 deletions tests/Unit/QuoteDraft.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -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();
}
});
Loading