mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0dea581db |
@@ -12,7 +12,10 @@ import {
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
import {
|
||||
buildAcceptAttribute,
|
||||
detectFileExtension,
|
||||
} from "@app/utils/fileUtils";
|
||||
import FileEditorThumbnail from "@app/components/fileEditor/FileEditorThumbnail";
|
||||
import AddFileCard from "@app/components/fileEditor/AddFileCard";
|
||||
import FilePickerModal from "@app/components/shared/FilePickerModal";
|
||||
@@ -521,6 +524,9 @@ const FileEditor = ({
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
accept={
|
||||
buildAcceptAttribute(supportedExtensions) || undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Text, Anchor } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
@@ -8,15 +8,18 @@ import { useAllFiles } from "@app/contexts/FileContext";
|
||||
import { useFileManager } from "@app/hooks/useFileManager";
|
||||
import { StirlingFile } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import { buildAcceptAttribute } from "@app/utils/fileUtils";
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: StirlingFile[];
|
||||
minFiles?: number;
|
||||
supportedFormats?: string[];
|
||||
}
|
||||
|
||||
const FileStatusIndicator = ({
|
||||
selectedFiles = [],
|
||||
minFiles = 1,
|
||||
supportedFormats,
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal, onFileUpload } = useFilesModalContext();
|
||||
@@ -24,6 +27,11 @@ const FileStatusIndicator = ({
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||
|
||||
const acceptAttribute = useMemo(
|
||||
() => buildAcceptAttribute(supportedFormats ?? ["pdf"]),
|
||||
[supportedFormats],
|
||||
);
|
||||
|
||||
// Check if there are recent files
|
||||
useEffect(() => {
|
||||
const checkRecentFiles = async () => {
|
||||
@@ -42,7 +50,9 @@ const FileStatusIndicator = ({
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.accept = ".pdf,application/pdf";
|
||||
if (acceptAttribute) {
|
||||
input.accept = acceptAttribute;
|
||||
}
|
||||
input.onchange = (event) => {
|
||||
const files = Array.from((event.target as HTMLInputElement).files || []);
|
||||
if (files.length > 0) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface FilesToolStepProps {
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
minFiles?: number;
|
||||
supportedFormats?: string[];
|
||||
}
|
||||
|
||||
export function createFilesToolStep(
|
||||
@@ -28,6 +29,7 @@ export function createFilesToolStep(
|
||||
<FileStatusIndicator
|
||||
selectedFiles={props.selectedFiles}
|
||||
minFiles={props.minFiles}
|
||||
supportedFormats={props.supportedFormats}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { StirlingFile } from "@app/types/fileContext";
|
||||
import type { TooltipTip } from "@app/types/tips";
|
||||
import type { ExecuteDisabledReason } from "@app/hooks/tools/shared/toolOperationTypes";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: StirlingFile[];
|
||||
@@ -20,6 +21,12 @@ export interface FilesStepConfig {
|
||||
minFiles?: number;
|
||||
onCollapsedClick?: () => void;
|
||||
isVisible?: boolean;
|
||||
/**
|
||||
* Override the file types that the upload picker accepts. Defaults to the
|
||||
* `supportedFormats` declared on the currently selected tool (or just
|
||||
* `["pdf"]` when a tool does not declare any).
|
||||
*/
|
||||
supportedFormats?: string[];
|
||||
}
|
||||
|
||||
export interface MiddleStepConfig {
|
||||
@@ -94,11 +101,18 @@ export interface ToolFlowConfig<TParams = unknown> {
|
||||
/**
|
||||
* Creates a flexible tool flow with configurable steps and state management left to the tool.
|
||||
* Reduces boilerplate while allowing tools to manage their own collapse/expansion logic.
|
||||
*
|
||||
* NOTE: This must be called from inside a React function component (or another
|
||||
* hook) — it uses `useToolWorkflow()` to look up the active tool's
|
||||
* `supportedFormats` for the files-step upload picker.
|
||||
*/
|
||||
export function createToolFlow<TParams = unknown>(
|
||||
config: ToolFlowConfig<TParams>,
|
||||
) {
|
||||
const { selectedTool } = useToolWorkflow();
|
||||
const steps = createToolSteps();
|
||||
const filesSupportedFormats =
|
||||
config.files.supportedFormats ?? selectedTool?.supportedFormats;
|
||||
|
||||
return (
|
||||
<Stack gap="sm" p="sm">
|
||||
@@ -113,6 +127,7 @@ export function createToolFlow<TParams = unknown>(
|
||||
isCollapsed: config.files.isCollapsed,
|
||||
minFiles: config.files.minFiles,
|
||||
onCollapsedClick: config.files.onCollapsedClick,
|
||||
supportedFormats: filesSupportedFormats,
|
||||
})}
|
||||
|
||||
{/* Middle Steps */}
|
||||
|
||||
@@ -1042,6 +1042,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
maxFiles: -1,
|
||||
// Backend accepts a PDF *or* a raw scanned image (jpg/png/...).
|
||||
supportedFormats: [
|
||||
"pdf",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"bmp",
|
||||
"gif",
|
||||
"tif",
|
||||
"tiff",
|
||||
"webp",
|
||||
],
|
||||
endpoints: ["extract-image-scans"],
|
||||
operationConfig: scannerImageSplitOperationConfig,
|
||||
automationSettings: ScannerImageSplitSettings,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
/**
|
||||
* The "Files" step on the left-hand tool panel renders an Upload link (when
|
||||
* the user has no recent files cached in IndexedDB) that synthesises a native
|
||||
* `<input type="file">` and triggers a click. Each tool's upload picker should
|
||||
* advertise an `accept` attribute that matches the formats the underlying
|
||||
* endpoint actually consumes — PDF for everything except Convert (and a small
|
||||
* number of other tools that accept raw images, e.g. ScannerImageSplit).
|
||||
*
|
||||
* These specs install a hook on `HTMLInputElement.prototype.click` that
|
||||
* captures the synthesised input's `accept` value before the native picker
|
||||
* would open, then asserts on it. The hook also no-ops the click so the
|
||||
* browser does not try to show a file dialog under Playwright.
|
||||
*/
|
||||
|
||||
const TOOL_PANEL = '[data-sidebar="tool-panel"]';
|
||||
|
||||
async function captureUploadAccept(page: Page): Promise<string> {
|
||||
// Wait for the tool panel and upload link to render — FileStatusIndicator
|
||||
// returns null until its async recent-files check resolves.
|
||||
const uploadLink = page
|
||||
.locator(TOOL_PANEL)
|
||||
.getByText(/^upload$/i)
|
||||
.first();
|
||||
await expect(uploadLink).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.evaluate(() => {
|
||||
const w = window as unknown as {
|
||||
__uploadAccepts?: string[];
|
||||
__originalInputClick?: () => void;
|
||||
};
|
||||
w.__uploadAccepts = [];
|
||||
w.__originalInputClick = HTMLInputElement.prototype.click;
|
||||
HTMLInputElement.prototype.click = function () {
|
||||
if (this.type === "file") {
|
||||
w.__uploadAccepts!.push(this.accept);
|
||||
return;
|
||||
}
|
||||
w.__originalInputClick!.call(this);
|
||||
};
|
||||
});
|
||||
|
||||
await uploadLink.click();
|
||||
|
||||
const accepts = await page.evaluate(
|
||||
() => (window as unknown as { __uploadAccepts: string[] }).__uploadAccepts,
|
||||
);
|
||||
expect(accepts.length).toBeGreaterThan(0);
|
||||
return accepts[0]!;
|
||||
}
|
||||
|
||||
const PDF_ONLY_TOOLS = [
|
||||
{ route: "/add-stamp", label: "Add Stamp" },
|
||||
{ route: "/add-password", label: "Add Password" },
|
||||
{ route: "/compress", label: "Compress" },
|
||||
{ route: "/rotate", label: "Rotate" },
|
||||
{ route: "/sanitize", label: "Sanitize" },
|
||||
{ route: "/split", label: "Split" },
|
||||
{ route: "/watermark", label: "Add Watermark" },
|
||||
];
|
||||
|
||||
test.describe("Tool upload picker — accept attribute", () => {
|
||||
for (const tool of PDF_ONLY_TOOLS) {
|
||||
test(`${tool.label} upload picker accepts PDF only`, async ({ page }) => {
|
||||
await page.goto(tool.route);
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
const accept = await captureUploadAccept(page);
|
||||
const tokens = accept.split(",").map((t) => t.trim());
|
||||
|
||||
expect(tokens).toContain(".pdf");
|
||||
expect(tokens).toContain("application/pdf");
|
||||
// Should NOT advertise non-PDF formats
|
||||
expect(tokens).not.toContain(".docx");
|
||||
expect(tokens).not.toContain(".png");
|
||||
expect(tokens).not.toContain(".jpg");
|
||||
expect(tokens).not.toContain(".html");
|
||||
});
|
||||
}
|
||||
|
||||
test("Convert upload picker accepts the broad CONVERT_SUPPORTED_FORMATS list", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/convert");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
const accept = await captureUploadAccept(page);
|
||||
const tokens = accept.split(",").map((t) => t.trim());
|
||||
|
||||
// Convert supports many input formats — sample a representative few to
|
||||
// catch a regression where supportedFormats stops flowing through.
|
||||
expect(tokens).toContain(".pdf");
|
||||
expect(tokens).toContain(".docx");
|
||||
expect(tokens).toContain(".png");
|
||||
expect(tokens).toContain(".jpg");
|
||||
expect(tokens).toContain(".html");
|
||||
expect(tokens).toContain(".epub");
|
||||
});
|
||||
|
||||
test("ScannerImageSplit upload picker accepts both PDF and raw images", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/scanner-image-split");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
const accept = await captureUploadAccept(page);
|
||||
const tokens = accept.split(",").map((t) => t.trim());
|
||||
|
||||
expect(tokens).toContain(".pdf");
|
||||
expect(tokens).toContain(".jpg");
|
||||
expect(tokens).toContain(".png");
|
||||
// Should NOT pick up Convert-only formats like .docx
|
||||
expect(tokens).not.toContain(".docx");
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
});
|
||||
|
||||
const automateOperation = useAutomateOperation();
|
||||
const { regularTools: toolRegistry } = useToolRegistry();
|
||||
const { regularTools: toolRegistry, superTools } = useToolRegistry();
|
||||
const hasResults =
|
||||
automateOperation.files.length > 0 ||
|
||||
automateOperation.downloadUrl !== null;
|
||||
@@ -209,6 +209,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const filesStep = createFilesToolStep(createStep, {
|
||||
selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
supportedFormats: superTools.automate?.supportedFormats,
|
||||
});
|
||||
|
||||
const automationSteps = [
|
||||
|
||||
@@ -157,3 +157,59 @@ export function detectNonPdfFileType(
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const EXTENSION_MIME_TYPES: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
bmp: "image/bmp",
|
||||
svg: "image/svg+xml",
|
||||
tif: "image/tiff",
|
||||
tiff: "image/tiff",
|
||||
webp: "image/webp",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
txt: "text/plain",
|
||||
text: "text/plain",
|
||||
csv: "text/csv",
|
||||
json: "application/json",
|
||||
xml: "application/xml",
|
||||
rtf: "application/rtf",
|
||||
zip: "application/zip",
|
||||
md: "text/markdown",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
ppt: "application/vnd.ms-powerpoint",
|
||||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
odt: "application/vnd.oasis.opendocument.text",
|
||||
ods: "application/vnd.oasis.opendocument.spreadsheet",
|
||||
odp: "application/vnd.oasis.opendocument.presentation",
|
||||
eml: "message/rfc822",
|
||||
epub: "application/epub+zip",
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an HTML file input `accept` attribute string from a list of file
|
||||
* extensions. The result includes both the extension form (".pdf") and the
|
||||
* MIME type form ("application/pdf") when known, so browsers reliably restrict
|
||||
* the native picker.
|
||||
*/
|
||||
export function buildAcceptAttribute(extensions?: string[] | null): string {
|
||||
if (!extensions || extensions.length === 0) return "";
|
||||
|
||||
const tokens = new Set<string>();
|
||||
for (const raw of extensions) {
|
||||
if (!raw) continue;
|
||||
const ext = raw.replace(/^\./, "").toLowerCase();
|
||||
if (!ext) continue;
|
||||
tokens.add(`.${ext}`);
|
||||
const mime = EXTENSION_MIME_TYPES[ext];
|
||||
if (mime) tokens.add(mime);
|
||||
}
|
||||
|
||||
return Array.from(tokens).join(",");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user