FN-8037: add resizable image preview windows
Make pending task images compact and inspectable across task creation surfaces. - Extract shared pending-image thumbnail controls for Quick Add, Task Form, and inline creation. - Open full images in a responsive, movable, resizable FloatingWindow with Escape focus restoration. - Add styling, coverage, operator documentation, and a minor changeset. Files changed: .changeset/fn-8037-image-preview-modal.md | 7 ++ docs/dashboard-guide.md | 4 +- .../quick-entry-image-preview-modal.test.tsx | 60 ++++++++++ .../dashboard/app/components/FloatingWindow.css | 22 ++++ .../dashboard/app/components/InlineCreateCard.css | 14 ++- .../dashboard/app/components/InlineCreateCard.tsx | 26 ++--- .../app/components/PendingImagePreviews.css | 35 ++++++ .../app/components/PendingImagePreviews.tsx | 121 +++++++++++++++++++++ .../dashboard/app/components/QuickEntryBox.tsx | 27 ++--- packages/dashboard/app/components/TaskForm.tsx | 26 ++--- 10 files changed, 280 insertions(+), 62 deletions(-) Fusion-Task-Id: FN-8037 Fusion-Task-Lineage: 3d92f695-67c9-4791-bfcc-ad25f6aa56f4 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8037-image-preview-modal.md
Normal file
7
.changeset/fn-8037-image-preview-modal.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Quick Add image attachments now show compact previews you can tap to open full-size in a resizable window.
|
||||
category: feature
|
||||
dev: QuickEntryBox/TaskForm/InlineCreateCard pending-image previews shrink via InlineCreateCard.css and open in the shared FloatingWindow (dedicated floating-window--image-preview class, full-screen on mobile); remove button stays a separate click target.
|
||||
@@ -500,10 +500,10 @@ Create requests never send an explicit `column`. The task store resolves the lan
|
||||
|
||||
Optional workflow steps declared by the active workflow are available from the quick-add action row and the **New Task** dialog's inline quick buttons. For example, the coding workflow's browser verification option appears as a quick drop-down when that workflow is active; each option is seeded from the workflow step's `defaultOn` setting and is sent with the task's `enabledWorkflowSteps` payload at creation time.
|
||||
|
||||
<!-- FNXC:QuickAddAttachments 2026-06-30-00:00: Quick Add attachments use a compact icon-only paperclip while keeping the Attach action and pending image count in accessible labels. The same pending preview/upload path accepts image selection, paste, and direct drag/drop onto the Quick Add box. -->
|
||||
<!-- FNXC:QuickAddAttachments 2026-07-16-00:00: Quick Add attachments use a compact icon-only paperclip while keeping the Attach action and pending image count in accessible labels. The same pending preview/upload path accepts image selection, paste, and direct drag/drop onto the Quick Add box. Compact pending-image thumbnails are accessible open controls that show the full image in a movable/resizable window, which becomes a full-screen sheet on mobile. -->
|
||||
<!-- FNXC:QuickAddPriorityIndicator 2026-07-10-21:45: Quick Add keeps status controls in the bottom action cluster: GitHub tracking sits beside the paperclip attach button, Priority is icon-only with low/down, normal/flag, high/up, urgent/alert glyphs, and Fast is an icon-only lightning button.
|
||||
FNXC:PriorityColorCoding 2026-07-11-00:00: Priority glyphs share urgency colors across Quick Add, the New Task inline row, and task-card badges: low=info/blue, normal=muted, high=warning/amber, urgent=error/red. -->
|
||||
Quick Add image attachments use the paperclip icon button in the action row. Supported image files (`png`, `jpeg`, `gif`, `webp`) can be selected from that control, pasted into the Quick Add input, or dragged onto the Quick Add box; all three paths show pending previews before task creation and upload the images to the created task afterward. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips.
|
||||
Quick Add image attachments use the paperclip icon button in the action row. Supported image files (`png`, `jpeg`, `gif`, `webp`) can be selected from that control, pasted into the Quick Add input, or dragged onto the Quick Add box; all three paths show compact pending previews before task creation and upload the images to the created task afterward. Select a pending preview to inspect the full image in a movable, resizable window (a full-screen sheet on mobile); close it with Escape or the close control to return to the preview. The same bottom action row places the GitHub tracking override beside the paperclip; Priority is an icon-only control whose glyph changes by selected level (down arrow for low, flag for normal, up arrow for high, alert for urgent) and is color-coded by urgency (low blue/info, normal muted, high amber/warning, urgent red/error), and Fast is an icon-only lightning control. These icon-only controls keep accessible labels and the same create-payload behavior as the previous text chips.
|
||||
|
||||
Quick entry, inline quick-create, and the full **New Task** dialog all check for similar active tasks before creating. When possible duplicates exist, the warning lists each match by task description (falling back to title, then “No description”) and lets you open an existing task, cancel, or create anyway with the duplicates acknowledged.
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { PendingImagePreviews } from "../components/PendingImagePreviews";
|
||||
|
||||
const image = {
|
||||
file: new File(["image"], "preview.png", { type: "image/png" }),
|
||||
previewUrl: "blob:preview",
|
||||
};
|
||||
|
||||
describe("pending task image previews", () => {
|
||||
it.each([
|
||||
"quick-entry-preview",
|
||||
"task-form-preview",
|
||||
"inline-create-preview",
|
||||
])("opens and dismisses the shared %s preview affordance", async (testIdPrefix) => {
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
render(
|
||||
<PendingImagePreviews
|
||||
images={[image]}
|
||||
onRemove={onRemove}
|
||||
removeLabel="Remove image"
|
||||
testIdPrefix={testIdPrefix}
|
||||
/>,
|
||||
);
|
||||
|
||||
const openButton = screen.getByTestId(`${testIdPrefix}-open-0`);
|
||||
expect(openButton).toHaveAccessibleName("Open image preview.png");
|
||||
await user.click(openButton);
|
||||
|
||||
const window = screen.getByTestId("floating-window-pending-image-preview");
|
||||
expect(window).toHaveClass("floating-window--image-preview");
|
||||
expect(screen.getByRole("img", { name: "preview.png" })).toHaveAttribute("src", "blob:preview");
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByTestId("floating-window-pending-image-preview")).toBeNull());
|
||||
expect(openButton).toHaveFocus();
|
||||
|
||||
await user.keyboard("{Enter}");
|
||||
expect(screen.getByTestId("floating-window-pending-image-preview")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId(`${testIdPrefix}-remove-0`));
|
||||
expect(onRemove).toHaveBeenCalledWith(0);
|
||||
expect(screen.queryByTestId("floating-window-pending-image-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders no preview shells without pending images", () => {
|
||||
const { container } = render(
|
||||
<PendingImagePreviews
|
||||
images={[]}
|
||||
onRemove={vi.fn()}
|
||||
removeLabel="Remove image"
|
||||
testIdPrefix="quick-entry-preview"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".inline-create-previews")).toBeNull();
|
||||
expect(screen.queryByTestId("quick-entry-preview-open-0")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -123,6 +123,28 @@ On mobile/narrow app viewports, opening Quick Chat should present the full Chat
|
||||
.floating-window--chat .floating-window__resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-07-16-00:00:
|
||||
Pending task-attachment images open in a movable, resizable FloatingWindow on desktop. On narrow touch viewports the dedicated image-preview variant must instead fill the screen and remove desktop resize handles.
|
||||
*/
|
||||
.floating-window--image-preview {
|
||||
inset: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100dvh !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-width: 100vw !important;
|
||||
max-height: 100dvh !important;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.floating-window--image-preview .floating-window__resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:GitHubImport 2026-07-15-16:00:
|
||||
Import details use FloatingWindow for desktop drag and resize; on mobile this scoped variant becomes a full-screen sheet
|
||||
|
||||
@@ -166,21 +166,25 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-07-16-00:00:
|
||||
Pending-image previews share this compact tokenized footprint across QuickEntryBox, TaskForm, and InlineCreateCard so Board, List, and form composers keep their action areas usable. The thumbnail remains a crop; its shared renderer provides the full-size open action.
|
||||
*/
|
||||
.inline-create-previews {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
max-height: 120px;
|
||||
max-height: calc(var(--space-2xl) * 2.5);
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.inline-create-preview {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid var(--border);
|
||||
width: var(--space-2xl);
|
||||
height: var(--space-2xl);
|
||||
border: thin solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--card);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { WorkflowSelector } from "./WorkflowSelector";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
import { PendingImagePreviews } from "./PendingImagePreviews";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
const STORAGE_KEY = "kb-inline-create-text";
|
||||
@@ -868,24 +869,13 @@ export function InlineCreateCard({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, i) => (
|
||||
<div key={img.previewUrl} className="inline-create-preview">
|
||||
<img src={img.previewUrl} alt={img.file.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-create-preview-remove"
|
||||
onClick={() => removeImage(i)}
|
||||
disabled={submitting}
|
||||
title={t("inline.removeImage", "Remove image")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<PendingImagePreviews
|
||||
images={pendingImages}
|
||||
onRemove={removeImage}
|
||||
disabled={submitting}
|
||||
removeLabel={t("inline.removeImage", "Remove image")}
|
||||
testIdPrefix="inline-create-preview"
|
||||
/>
|
||||
{isExpanded && (
|
||||
<div id="inline-create-controls" className="inline-create-footer">
|
||||
<div className="inline-create-controls">
|
||||
|
||||
35
packages/dashboard/app/components/PendingImagePreviews.css
Normal file
35
packages/dashboard/app/components/PendingImagePreviews.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-07-16-00:00:
|
||||
The open button fills the compact shared thumbnail shell while the remove button remains an independent layered control. The preview image deliberately retains its natural dimensions in the resizable FloatingWindow body, whose scroll area is governed by the user-selected window size.
|
||||
*/
|
||||
.pending-image-preview__open {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pending-image-preview__open:focus-visible {
|
||||
outline: var(--focus-ring-width, thin) solid var(--focus-ring, var(--color-primary));
|
||||
outline-offset: calc(var(--space-xs) * -1);
|
||||
}
|
||||
|
||||
.pending-image-preview__modal-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.pending-image-preview__modal-content img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
121
packages/dashboard/app/components/PendingImagePreviews.tsx
Normal file
121
packages/dashboard/app/components/PendingImagePreviews.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FloatingWindow } from "./FloatingWindow";
|
||||
import "./PendingImagePreviews.css";
|
||||
|
||||
export interface PendingImagePreviewItem {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
}
|
||||
|
||||
interface PendingImagePreviewsProps {
|
||||
images: PendingImagePreviewItem[];
|
||||
onRemove: (index: number) => void;
|
||||
disabled?: boolean;
|
||||
removeLabel: string;
|
||||
testIdPrefix: string;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:QuickAddAttachments 2026-07-16-00:00:
|
||||
QuickEntryBox, TaskForm, and InlineCreateCard must expose identical pending-image open and remove controls. Keeping the floating preview here prevents keyboard dismissal, focus restoration, and blob-URL removal behavior from drifting between task-creation surfaces.
|
||||
*/
|
||||
export function PendingImagePreviews({
|
||||
images,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
removeLabel,
|
||||
testIdPrefix,
|
||||
}: PendingImagePreviewsProps) {
|
||||
const [selectedPreviewUrl, setSelectedPreviewUrl] = useState<string | null>(null);
|
||||
const returnFocusRef = useRef<HTMLButtonElement | null>(null);
|
||||
const selectedImage = selectedPreviewUrl
|
||||
? images.find((image) => image.previewUrl === selectedPreviewUrl) ?? null
|
||||
: null;
|
||||
|
||||
const closePreview = useCallback((restoreFocus = true) => {
|
||||
const buttonToFocus = returnFocusRef.current;
|
||||
setSelectedPreviewUrl(null);
|
||||
returnFocusRef.current = null;
|
||||
if (restoreFocus) {
|
||||
requestAnimationFrame(() => buttonToFocus?.focus());
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedImage) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePreview();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [closePreview, selectedImage]);
|
||||
|
||||
const openPreview = useCallback((previewUrl: string, button: HTMLButtonElement) => {
|
||||
returnFocusRef.current = button;
|
||||
setSelectedPreviewUrl(previewUrl);
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback((index: number, previewUrl: string) => {
|
||||
if (selectedPreviewUrl === previewUrl) {
|
||||
closePreview(false);
|
||||
}
|
||||
onRemove(index);
|
||||
}, [closePreview, onRemove, selectedPreviewUrl]);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="inline-create-previews">
|
||||
{images.map((image, index) => (
|
||||
<div key={image.previewUrl} className="inline-create-preview">
|
||||
<button
|
||||
type="button"
|
||||
className="pending-image-preview__open"
|
||||
onClick={(event) => openPreview(image.previewUrl, event.currentTarget)}
|
||||
aria-label={`Open image ${image.file.name}`}
|
||||
data-testid={`${testIdPrefix}-open-${index}`}
|
||||
>
|
||||
<img src={image.previewUrl} alt="" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-create-preview-remove"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRemove(index, image.previewUrl);
|
||||
}}
|
||||
disabled={disabled}
|
||||
title={removeLabel}
|
||||
aria-label={removeLabel}
|
||||
data-testid={`${testIdPrefix}-remove-${index}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedImage && (
|
||||
<FloatingWindow
|
||||
title={selectedImage.file.name}
|
||||
onClose={closePreview}
|
||||
windowKey="pending-image-preview"
|
||||
defaultSize={{ width: 640, height: 480 }}
|
||||
minSize={{ width: 320, height: 240 }}
|
||||
className="floating-window--image-preview"
|
||||
suspendGeometryPersistenceOnMobile
|
||||
ariaLabel={`Image preview: ${selectedImage.file.name}`}
|
||||
>
|
||||
<div className="pending-image-preview__modal-content">
|
||||
<img src={selectedImage.previewUrl} alt={selectedImage.file.name} />
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||
import { WorkflowIcon } from "./WorkflowIcon";
|
||||
import { PendingImagePreviews } from "./PendingImagePreviews";
|
||||
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
|
||||
|
||||
const STORAGE_KEY = "kb-quick-entry-text";
|
||||
@@ -2348,25 +2349,13 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, index) => (
|
||||
<div key={img.previewUrl} className="inline-create-preview">
|
||||
<img src={img.previewUrl} alt={img.file.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-create-preview-remove"
|
||||
onClick={() => removeImage(index)}
|
||||
disabled={isSubmitting}
|
||||
title={t("tasks.removeImage", "Remove image")}
|
||||
data-testid={`quick-entry-preview-remove-${index}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<PendingImagePreviews
|
||||
images={pendingImages}
|
||||
onRemove={removeImage}
|
||||
disabled={isSubmitting}
|
||||
removeLabel={t("tasks.removeImage", "Remove image")}
|
||||
testIdPrefix="quick-entry-preview"
|
||||
/>
|
||||
{isModelMenuOpen && portalRoot && modelMenuPosition && createPortal(
|
||||
<div
|
||||
ref={modelMenuPortalRef}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTra
|
||||
import { getPriorityColorVar, getPriorityIcon, getPriorityLabel } from "../utils/priorityIndicator";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { WorkflowIcon } from "./WorkflowIcon";
|
||||
import { PendingImagePreviews } from "./PendingImagePreviews";
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string {
|
||||
if (status === "online") return t("taskForm.nodeStatusOnline", "Online");
|
||||
@@ -1200,24 +1201,13 @@ export function TaskForm({
|
||||
{/* Attachments */}
|
||||
<div className="form-group">
|
||||
<label>{t("taskForm.attachmentsLabel", "Attachments")}</label>
|
||||
{pendingImages.length > 0 && (
|
||||
<div className="inline-create-previews">
|
||||
{pendingImages.map((img, i) => (
|
||||
<div key={img.previewUrl} className="inline-create-preview">
|
||||
<img src={img.previewUrl} alt={img.file.name} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-create-preview-remove"
|
||||
onClick={() => removeImage(i)}
|
||||
disabled={disabled}
|
||||
title={t("taskForm.removeImage", "Remove image")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<PendingImagePreviews
|
||||
images={pendingImages}
|
||||
onRemove={removeImage}
|
||||
disabled={disabled}
|
||||
removeLabel={t("taskForm.removeImage", "Remove image")}
|
||||
testIdPrefix="task-form-preview"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
||||
Reference in New Issue
Block a user