From c8615518a6031cf66e532e2fbda09ffcc0a17bcc Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 12 Nov 2025 15:02:43 +0000 Subject: [PATCH] Addition of the Show JavaScript tool (#4877) # Description of Changes - Added the show javascript tool. --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .../public/locales/en-GB/translation.json | 17 +- .../public/locales/en-US/translation.json | 17 +- .../core/components/fileEditor/FileEditor.tsx | 51 ++- .../shared/filePreview/DocumentThumbnail.tsx | 9 +- .../shared/filePreview/getFileTypeIcon.tsx | 32 ++ .../tools/shared/ReviewToolStep.tsx | 42 +- .../tools/shared/createToolFlow.tsx | 15 +- .../components/tools/showJS/ShowJSView.css | 131 ++++++ .../components/tools/showJS/ShowJSView.tsx | 305 ++++++++++++++ .../src/core/components/tools/showJS/utils.ts | 382 ++++++++++++++++++ .../core/data/useTranslatedToolRegistry.tsx | 5 +- .../hooks/tools/showJS/useShowJSOperation.ts | 137 +++++++ .../hooks/tools/showJS/useShowJSParameters.ts | 21 + frontend/src/core/styles/theme.css | 10 + frontend/src/core/tools/ShowJS.tsx | 150 +++++++ 15 files changed, 1281 insertions(+), 43 deletions(-) create mode 100644 frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx create mode 100644 frontend/src/core/components/tools/showJS/ShowJSView.css create mode 100644 frontend/src/core/components/tools/showJS/ShowJSView.tsx create mode 100644 frontend/src/core/components/tools/showJS/utils.ts create mode 100644 frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts create mode 100644 frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts create mode 100644 frontend/src/core/tools/ShowJS.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 3e50f5cee..4e9904f19 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -99,6 +99,8 @@ "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", + "back": "Back", + "nothingToUndo": "Nothing to undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", @@ -2753,7 +2755,14 @@ "title": "Show Javascript", "header": "Show Javascript", "downloadJS": "Download Javascript", - "submit": "Show" + "submit": "Show", + "results": "Result", + "processing": "Extracting JavaScript...", + "done": "JavaScript extracted", + "singleFileWarning": "This tool only supports one file at a time. Please select a single file.", + "view": { + "title": "Extracted JavaScript" + } }, "redact": { "tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact", @@ -4587,6 +4596,12 @@ } }, "common": { + "previous": "Previous", + "next": "Next", + "collapse": "Collapse", + "expand": "Expand", + "collapsed": "collapsed", + "lines": "lines", "copy": "Copy", "copied": "Copied!", "refresh": "Refresh", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 8c787ee61..10e923c90 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -98,6 +98,8 @@ "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", + "back": "Back", + "nothingToUndo": "Nothing to undo", "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", @@ -3050,7 +3052,14 @@ "title": "Show Javascript", "header": "Show Javascript", "downloadJS": "Download Javascript", - "submit": "Show" + "submit": "Show", + "results": "Result", + "processing": "Extracting JavaScript...", + "done": "JavaScript extracted", + "singleFileWarning": "This tool only supports one file at a time. Please select a single file.", + "view": { + "title": "Extracted JavaScript" + } }, "redact": { "tags": "Redact,Hide,black out,black,marker,hidden,manual", @@ -4909,6 +4918,12 @@ } }, "common": { + "previous": "Previous", + "next": "Next", + "collapse": "Collapse", + "expand": "Expand", + "collapsed": "collapsed", + "lines": "lines", "copy": "Copy", "copied": "Copied!", "refresh": "Refresh", diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index fbafa8b89..76e6bb1a2 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -14,6 +14,7 @@ import { FileId, StirlingFile } from '@app/types/fileContext'; import { alert } from '@app/components/toast'; import { downloadBlob } from '@app/utils/downloadUtils'; import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; interface FileEditorProps { @@ -65,6 +66,15 @@ const FileEditor = ({ }, []); const [selectionMode, setSelectionMode] = useState(toolMode); + // Current tool (for enforcing maxFiles limits) + const { selectedTool } = useToolWorkflow(); + + // Compute effective max allowed files based on the active tool and mode + const maxAllowed = useMemo(() => { + const rawMax = selectedTool?.maxFiles; + return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; + }, [selectedTool?.maxFiles, toolMode]); + // Enable selection mode automatically in tool mode useEffect(() => { if (toolMode) { @@ -83,7 +93,10 @@ const FileEditor = ({ const localSelectedIds = contextSelectedIds; const handleSelectAllFiles = useCallback(() => { - setSelectedFiles(state.files.ids); + // Respect maxAllowed: if limited, select the last N files + const allIds = state.files.ids; + const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds; + setSelectedFiles(idsToSelect); try { clearAllFileErrors(); } catch (error) { @@ -91,7 +104,7 @@ const FileEditor = ({ console.warn('Failed to clear file errors on select all:', error); } } - }, [state.files.ids, setSelectedFiles, clearAllFileErrors]); + }, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]); const handleDeselectAllFiles = useCallback(() => { setSelectedFiles([]); @@ -131,6 +144,13 @@ const FileEditor = ({ // - HTML ZIPs stay intact // - Non-ZIP files pass through unchanged await addFiles(uploadedFiles, { selectFiles: true }); + // After auto-selection, enforce maxAllowed if needed + if (Number.isFinite(maxAllowed)) { + const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id); + if (nowSelectedIds.length > maxAllowed) { + setSelectedFiles(nowSelectedIds.slice(-maxAllowed)); + } + } showStatus(`Added ${uploadedFiles.length} file(s)`, 'success'); } } catch (err) { @@ -138,7 +158,7 @@ const FileEditor = ({ showError(errorMessage); console.error('File processing error:', err); } - }, [addFiles, showStatus, showError]); + }, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]); const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; @@ -156,24 +176,33 @@ const FileEditor = ({ newSelection = currentSelectedIds.filter(id => id !== contextFileId); } else { // Add file to selection - // In tool mode, typically allow multiple files unless specified otherwise - const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools + // Determine max files allowed from the active tool (negative or undefined means unlimited) + const rawMax = selectedTool?.maxFiles; + const maxAllowed = (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; if (maxAllowed === 1) { + // Only one file allowed -> replace selection with the new file newSelection = [contextFileId]; } else { - // Check if we've hit the selection limit - if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) { - showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning'); - return; + // If at capacity, drop the oldest selected and append the new one + if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) { + newSelection = [...currentSelectedIds.slice(1), contextFileId]; + } else { + newSelection = [...currentSelectedIds, contextFileId]; } - newSelection = [...currentSelectedIds, contextFileId]; } } // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]); + }, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles]); + + // Enforce maxAllowed when tool changes or when an external action sets too many selected files + useEffect(() => { + if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) { + setSelectedFiles(selectedFileIds.slice(-maxAllowed)); + } + }, [maxAllowed, selectedFileIds, setSelectedFiles]); // File reordering handler for drag and drop diff --git a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx index 5ace7d03f..b60efa208 100644 --- a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, Center, Image } from '@mantine/core'; -import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import { getFileTypeIcon } from '@app/components/shared/filePreview/getFileTypeIcon'; import { StirlingFileStub } from '@app/types/fileContext'; import { PrivateContent } from '@app/components/shared/PrivateContent'; @@ -53,12 +53,7 @@ const DocumentThumbnail: React.FC = ({
- + {getFileTypeIcon(file)}
{children} diff --git a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx new file mode 100644 index 000000000..afcacad07 --- /dev/null +++ b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import JavascriptIcon from "@mui/icons-material/Javascript"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { detectFileExtension } from "@app/utils/fileUtils"; + +type FileLike = File | StirlingFileStub; + +/** + * Returns an appropriate file type icon for the provided file. + * - Uses the real file type and extension to decide the icon. + * - No any-casts; accepts File or StirlingFileStub. + */ +export function getFileTypeIcon(file: FileLike, size: number | string = "2rem"): React.ReactElement { + const name = (file?.name ?? "").toLowerCase(); + const mime = (file?.type ?? "").toLowerCase(); + const ext = detectFileExtension(name); + + // JavaScript + if (ext === "js" || mime.includes("javascript")) { + return ; + } + + // PDF + if (ext === "pdf" || mime === "application/pdf") { + return ; + } + + // Fallback generic + return ; +} diff --git a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx index 30c8c906e..2314a6302 100644 --- a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx @@ -14,7 +14,7 @@ export interface ReviewToolStepProps { operation: ToolOperationHook; title?: string; onFileClick?: (file: File) => void; - onUndo: () => void; + onUndo?: () => void; isCollapsed?: boolean; onCollapsedClick?: () => void; } @@ -26,14 +26,14 @@ function ReviewStepContent({ }: { operation: ToolOperationHook; onFileClick?: (file: File) => void; - onUndo: () => void; + onUndo?: () => void; }) { const { t } = useTranslation(); const stepRef = useRef(null); const handleUndo = async () => { try { - onUndo(); + onUndo?.(); } catch (error) { // Error is already handled by useToolOperation, just reset loading state console.error("Undo operation failed:", error); @@ -73,17 +73,19 @@ function ReviewStepContent({ /> )} - - - + {onUndo && ( + + + + )} {operation.downloadUrl && ( + + + + +
+
+ {lines.map((tokens, ln) => { + if (isHidden(ln)) return null; + const end = startToEnd.get(ln); + const folded = end != null && collapsed.has(ln); + let pos = 0; + const lineMatches = matches.map((m, idx) => ({ ...m, idx })).filter((m) => m.line === ln); + const content: React.ReactNode[] = []; + tokens.forEach((tok, ti) => { + const textSeg = tok.text; + const tokenStart = pos; + const tokenEnd = pos + textSeg.length; + + if (!query || lineMatches.length === 0) { + const cls = tok.type === "plain" ? undefined : `tok-${tok.type}`; + content.push( + + {textSeg} + , + ); + pos = tokenEnd; + return; + } + + // Collect matches that intersect this token + const matchesInToken = lineMatches + .filter((m) => m.start < tokenEnd && m.end > tokenStart) + .sort((a, b) => a.start - b.start); + + if (matchesInToken.length === 0) { + const cls = tok.type === "plain" ? undefined : `tok-${tok.type}`; + content.push( + + {textSeg} + , + ); + pos = tokenEnd; + return; + } + + let cursor = 0; + const tokenCls = tok.type === "plain" ? "" : `tok-${tok.type}`; + + matchesInToken.forEach((m, mi) => { + const localStart = Math.max(0, m.start - tokenStart); + const localEnd = Math.min(textSeg.length, m.end - tokenStart); + + // before match + if (localStart > cursor) { + const beforeText = textSeg.slice(cursor, localStart); + const cls = tokenCls || undefined; + content.push( + + {beforeText} + , + ); + } + // matched piece + const hitText = textSeg.slice(localStart, localEnd); + const hitCls = + ["search-hit", m.idx === active ? "search-hit-active" : "", tokenCls].filter(Boolean).join(" ") || + undefined; + content.push( + + {hitText} + , + ); + cursor = localEnd; + }); + + // tail after last match + if (cursor < textSeg.length) { + const tailText = textSeg.slice(cursor); + const cls = tokenCls || undefined; + content.push( + + {tailText} + , + ); + } + + pos = tokenEnd; + }); + return ( +
+
+ {end != null ? ( + + ) : ( + + )} + {ln + 1} +
+
+ {content} + {folded && {"{...}"}} +
+
+ ); + })} +
+
+
+
+ + ); +}; + +export default ShowJSView; diff --git a/frontend/src/core/components/tools/showJS/utils.ts b/frontend/src/core/components/tools/showJS/utils.ts new file mode 100644 index 000000000..c5cca2488 --- /dev/null +++ b/frontend/src/core/components/tools/showJS/utils.ts @@ -0,0 +1,382 @@ +export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain"; +export type ShowJsToken = { type: ShowJsTokenType; text: string }; + +const JS_KEYWORDS = new Set([ + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "export", + "extends", + "finally", + "for", + "function", + "if", + "import", + "in", + "instanceof", + "let", + "new", + "return", + "super", + "switch", + "this", + "throw", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", + "await", + "of", +]); + +export function tokenizeToLines(src: string, keywords: Set = JS_KEYWORDS): ShowJsToken[][] { + const lines: ShowJsToken[][] = []; + let current: ShowJsToken[] = []; + let i = 0; + let inBlockCom = false; + let inLineCom = false; + let inString: '"' | "'" | "`" | null = null; + let escaped = false; + + const push = (type: ShowJsTokenType, s: string) => { + if (s) { + current.push({ type, text: s }); + } + }; + + // Named actions for readability + const advance = (n: number = 1) => { + i += n; + }; + const handleNewline = () => { + lines.push(current); + current = []; + inLineCom = false; + advance(); + }; + const handleInLineCommentChar = (ch: string) => { + push("com", ch); + advance(); + }; + const handleBlockCommentEnd = () => { + push("com", "*/"); + inBlockCom = false; + advance(2); + }; + const handleInBlockCommentChar = (ch: string) => { + push("com", ch); + advance(); + }; + const handleInStringChar = (ch: string) => { + push("str", ch); + if (!escaped) { + const isEscape = ch === "\\"; + const isStringClose = ch === inString; + if (isEscape) { + escaped = true; + } else if (isStringClose) { + inString = null; + } + } else { + escaped = false; + } + advance(); + }; + const startLineComment = () => { + push("com", "//"); + inLineCom = true; + advance(2); + }; + const startBlockComment = () => { + push("com", "/*"); + inBlockCom = true; + advance(2); + }; + const startString = (ch: '"' | "'" | "`") => { + inString = ch; + push("str", ch); + advance(); + }; + const pushNumberToken = () => { + let j = i + 1; + const isNumberContinuation = (c: string) => /[0-9._xobA-Fa-f]/.test(c); + while (j < src.length && isNumberContinuation(src[j])) { + j++; + } + push("num", src.slice(i, j)); + i = j; + }; + const pushIdentifierToken = () => { + let j = i + 1; + const isIdentContinuation = (c: string) => /[A-Za-z0-9_$]/.test(c); + while (j < src.length && isIdentContinuation(src[j])) { + j++; + } + const id = src.slice(i, j); + const isKeyword = keywords.has(id); + push(isKeyword ? "kw" : "plain", id); + i = j; + }; + const pushPlainChar = (ch: string) => { + push("plain", ch); + advance(); + }; + + while (i < src.length) { + const ch = src[i]; + const next = src[i + 1]; + + // Named conditions + const isNewline = ch === "\n"; + const isLineCommentStart = ch === "/" && next === "/"; + const isBlockCommentStart = ch === "/" && next === "*"; + const isStringDelimiter = ch === "'" || ch === '"' || ch === "`"; + const isDigit = /[0-9]/.test(ch); + const isIdentifierStart = /[A-Za-z_$]/.test(ch); + + if (isNewline) { + handleNewline(); + continue; + } + + if (inLineCom) { + handleInLineCommentChar(ch); + continue; + } + + if (inBlockCom) { + const isBlockCommentEnd = ch === "*" && next === "/"; + if (isBlockCommentEnd) { + handleBlockCommentEnd(); + continue; + } + handleInBlockCommentChar(ch); + continue; + } + + if (inString) { + handleInStringChar(ch); + continue; + } + + if (isLineCommentStart) { + startLineComment(); + continue; + } + + if (isBlockCommentStart) { + startBlockComment(); + continue; + } + + if (isStringDelimiter) { + startString(ch as '"' | "'" | "`"); + continue; + } + + if (isDigit) { + pushNumberToken(); + continue; + } + + if (isIdentifierStart) { + pushIdentifierToken(); + continue; + } + + pushPlainChar(ch); + } + + lines.push(current); + return lines; +} + +export function computeBlocks(src: string): Array<{ start: number; end: number }> { + const res: Array<{ start: number; end: number }> = []; + let i = 0; + let line = 0; + let inBlock = false; + let inLine = false; + let str: '"' | "'" | "`" | null = null; + let esc = false; + const stack: number[] = []; + + // Actions + const advance = (n: number = 1) => { + i += n; + }; + const handleNewline = () => { + line++; + inLine = false; + advance(); + }; + const startLineComment = () => { + inLine = true; + advance(2); + }; + const startBlockComment = () => { + inBlock = true; + advance(2); + }; + const endBlockComment = () => { + inBlock = false; + advance(2); + }; + const startString = (delim: '"' | "'" | "`") => { + str = delim; + advance(); + }; + const handleStringChar = (ch: string) => { + if (!esc) { + const isEscape = ch === "\\"; + const isClose = ch === str; + if (isEscape) { + esc = true; + } else if (isClose) { + str = null; + } + } else { + esc = false; + } + advance(); + }; + const pushOpenBrace = () => { + stack.push(line); + advance(); + }; + const handleCloseBrace = () => { + const s = stack.pop(); + if (s != null && line > s) { + res.push({ start: s, end: line }); + } + advance(); + }; + + while (i < src.length) { + const ch = src[i]; + const nx = src[i + 1]; + + // Conditions + const isNewline = ch === "\n"; + const isLineCommentStart = ch === "/" && nx === "/"; + const isBlockCommentStart = ch === "/" && nx === "*"; + const isBlockCommentEnd = ch === "*" && nx === "/"; + const isStringDelimiter = ch === "'" || ch === '"' || ch === "`"; + const isOpenBrace = ch === "{"; + const isCloseBrace = ch === "}"; + + if (isNewline) { + handleNewline(); + continue; + } + if (inLine) { + advance(); + continue; + } + if (inBlock) { + if (isBlockCommentEnd) { + endBlockComment(); + } else { + advance(); + } + continue; + } + if (str) { + handleStringChar(ch); + continue; + } + if (isLineCommentStart) { + startLineComment(); + continue; + } + if (isBlockCommentStart) { + startBlockComment(); + continue; + } + if (isStringDelimiter) { + startString(ch as '"' | "'" | "`"); + continue; + } + if (isOpenBrace) { + pushOpenBrace(); + continue; + } + if (isCloseBrace) { + handleCloseBrace(); + continue; + } + advance(); + } + return res; +} + +export function computeSearchMatches( + lines: ShowJsToken[][], + query: string, +): Array<{ line: number; start: number; end: number }> { + if (!query) { + return []; + } + const q = query.toLowerCase(); + const list: Array<{ line: number; start: number; end: number }> = []; + lines.forEach((toks, ln) => { + const raw = toks.map((t) => t.text).join(""); + let idx = 0; + while (true) { + const pos = raw.toLowerCase().indexOf(q, idx); + if (pos === -1) { + break; + } + list.push({ + line: ln, + start: pos, + end: pos + q.length, + }); + idx = pos + Math.max(1, q.length); + } + }); + return list; +} + +export async function copyTextToClipboard(text: string, fallbackElement?: HTMLElement | null): Promise { + try { + if (typeof navigator !== "undefined" && navigator.clipboard) { + await navigator.clipboard.writeText(text || ""); + return true; + } + } catch { + // fall through to fallback + } + if (typeof document === "undefined" || !fallbackElement) return false; + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(fallbackElement); + selection?.removeAllRanges(); + selection?.addRange(range); + try { + document.execCommand("copy"); + return true; + } finally { + selection?.removeAllRanges(); + } +} + +export function triggerDownload(url: string, filename: string): void { + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} diff --git a/frontend/src/core/data/useTranslatedToolRegistry.tsx b/frontend/src/core/data/useTranslatedToolRegistry.tsx index 82c02b233..bcd11478b 100644 --- a/frontend/src/core/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/core/data/useTranslatedToolRegistry.tsx @@ -121,6 +121,7 @@ import RemoveBlanksSettings from "@app/components/tools/removeBlanks/RemoveBlank import AddPageNumbersAutomationSettings from "@app/components/tools/addPageNumbers/AddPageNumbersAutomationSettings"; import OverlayPdfsSettings from "@app/components/tools/overlayPdfs/OverlayPdfsSettings"; import ValidateSignature from "@app/tools/ValidateSignature"; +import ShowJS from "@app/tools/ShowJS"; import Automate from "@app/tools/Automate"; import Compare from "@app/tools/Compare"; import { CONVERT_SUPPORTED_FORMATS } from "@app/constants/convertSupportedFornats"; @@ -714,10 +715,12 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog { showJS: { icon: , name: t("home.showJS.title", "Show JavaScript"), - component: null, + component: ShowJS, description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"), categoryId: ToolCategoryId.ADVANCED_TOOLS, subcategoryId: SubcategoryId.DEVELOPER_TOOLS, + maxFiles: 1, + endpoints: ["show-javascript"], synonyms: getSynonyms(t, "showJS"), supportsAutomate: false, automationSettings: null diff --git a/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts new file mode 100644 index 000000000..081fc2723 --- /dev/null +++ b/frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts @@ -0,0 +1,137 @@ +import { useCallback, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import apiClient from '@app/services/apiClient'; +import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation'; +import type { StirlingFile } from '@app/types/fileContext'; +import { extractErrorMessage } from '@app/utils/toolErrorHandler'; +import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters'; +import type { ResponseType } from 'axios'; + +export interface ShowJSOperationHook extends ToolOperationHook { + scriptText: string | null; +} + +export const useShowJSOperation = (): ShowJSOperationHook => { + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState(''); + const [errorMessage, setErrorMessage] = useState(null); + const [files, setFiles] = useState([]); + const [downloadUrl, setDownloadUrl] = useState(null); + const [downloadFilename, setDownloadFilename] = useState(''); + const [scriptText, setScriptText] = useState(null); + + const cancelRequested = useRef(false); + const previousUrl = useRef(null); + + const cleanupDownloadUrl = useCallback(() => { + if (previousUrl.current) { + URL.revokeObjectURL(previousUrl.current); + previousUrl.current = null; + } + }, []); + + const resetResults = useCallback(() => { + cancelRequested.current = false; + setScriptText(null); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + setStatus(''); + setErrorMessage(null); + }, [cleanupDownloadUrl]); + + const clearError = useCallback(() => { + setErrorMessage(null); + }, []); + + const executeOperation = useCallback( + async (_params: ShowJSParameters, selectedFiles: StirlingFile[]) => { + if (selectedFiles.length === 0) { + setErrorMessage(t('noFileSelected', 'No files selected')); + return; + } + + cancelRequested.current = false; + setIsLoading(true); + setStatus(t('showJS.processing', 'Extracting JavaScript...')); + setErrorMessage(null); + setScriptText(null); + setFiles([]); + cleanupDownloadUrl(); + setDownloadUrl(null); + setDownloadFilename(''); + + try { + const file = selectedFiles[0]; + const formData = new FormData(); + formData.append('fileInput', file); + + const response = await apiClient.post('/api/v1/misc/show-javascript', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + responseType: 'text' as ResponseType, + transformResponse: [(data) => data], + }); + + const text: string = typeof response.data === 'string' ? response.data : ''; + setScriptText(text); + + // Optional: prepare a downloadable file + const outFile = new File([text], (file.name?.replace(/\.[^.]+$/, '') || 'extracted') + '.js', { + type: 'application/javascript', + }); + setFiles([outFile]); + const blobUrl = URL.createObjectURL(outFile); + previousUrl.current = blobUrl; + setDownloadUrl(blobUrl); + setDownloadFilename(outFile.name); + + setStatus(t('showJS.done', 'JavaScript extracted')); + } catch (error: unknown) { + setErrorMessage(extractErrorMessage(error)); + setStatus(''); + } finally { + setIsLoading(false); + } + }, + [t, cleanupDownloadUrl] + ); + + const cancelOperation = useCallback(() => { + cancelRequested.current = true; + setIsLoading(false); + setStatus(t('operationCancelled', 'Operation cancelled')); + }, [t]); + + const undoOperation = useCallback(async () => { + // No-op for this tool + setStatus(t('nothingToUndo', 'Nothing to undo')); + }, [t]); + + return { + // State (align with ToolOperationHook) + files, + thumbnails: [], + isGeneratingThumbnails: false, + downloadUrl, + downloadFilename, + isLoading, + status, + errorMessage, + progress: null, + + // Custom state + scriptText, + + // Actions + executeOperation, + resetResults, + clearError, + cancelOperation, + undoOperation, + }; +}; + + diff --git a/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts b/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts new file mode 100644 index 000000000..67bcaca56 --- /dev/null +++ b/frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts @@ -0,0 +1,21 @@ +import { useBaseParameters, type BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters'; +import { BaseParameters } from '@app/types/parameters'; + +export interface ShowJSParameters extends BaseParameters { + // Extends BaseParameters - ready for future parameter additions if needed +} + +export const defaultParameters: ShowJSParameters = { + // No parameters needed +}; + + +export type ShowJSParametersHook = BaseParametersHook; + +export const useShowJSParameters = (): ShowJSParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'show-javascript', + }); +}; + diff --git a/frontend/src/core/styles/theme.css b/frontend/src/core/styles/theme.css index 22c90d090..3051ced06 100644 --- a/frontend/src/core/styles/theme.css +++ b/frontend/src/core/styles/theme.css @@ -302,6 +302,11 @@ --pdf-light-simulated-page-bg: 255 255 255; --pdf-light-simulated-page-text: 15 23 42; + /* Code token colors (light mode) */ + --code-kw-color: #1d4ed8; /* blue-700 */ + --code-str-color: #16a34a; /* green-600 */ + --code-num-color: #4338ca; /* indigo-700 */ + --code-com-color: #6b7280; /* gray-500 */ /* Compare tool specific colors - only for colors that don't have existing theme pairs */ --compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45); --compare-upload-dropzone-border: rgba(148, 163, 184, 0.6); @@ -534,6 +539,11 @@ --modal-content-bg: #2A2F36; --modal-header-border: rgba(255, 255, 255, 0.08); + /* Code token colors (dark mode - Cursor-like) */ + --code-kw-color: #C792EA; /* purple */ + --code-str-color: #C3E88D; /* green */ + --code-num-color: #F78C6C; /* orange */ + --code-com-color: #697098; /* muted gray-blue */ /* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */ --compare-upload-dropzone-bg: rgba(31, 35, 41, 0.45); --compare-upload-dropzone-border: rgba(75, 85, 99, 0.6); diff --git a/frontend/src/core/tools/ShowJS.tsx b/frontend/src/core/tools/ShowJS.tsx new file mode 100644 index 000000000..234298d9b --- /dev/null +++ b/frontend/src/core/tools/ShowJS.tsx @@ -0,0 +1,150 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; +import { createToolFlow } from '@app/components/tools/shared/createToolFlow'; +import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool'; +import type { BaseToolProps, ToolComponent } from '@app/types/tool'; +import { useShowJSParameters, defaultParameters } from '@app/hooks/tools/showJS/useShowJSParameters'; +import { useShowJSOperation, type ShowJSOperationHook } from '@app/hooks/tools/showJS/useShowJSOperation'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +import ShowJSView from '@app/components/tools/showJS/ShowJSView'; +import { useFileSelection } from '@app/contexts/file/fileHooks'; + +const ShowJS = (props: BaseToolProps) => { + const { t } = useTranslation(); + const { actions: navigationActions } = useNavigationActions(); + const navigationState = useNavigationState(); + + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + } = useToolWorkflow(); + + const VIEW_ID = 'showJSView'; + const WORKBENCH_ID = 'custom:showJS' as const; + const viewIcon = useMemo(() => , []); + + const base = useBaseTool('showJS', useShowJSParameters, useShowJSOperation, props, { minFiles: 1 }); + const operation = base.operation as ShowJSOperationHook; + const hasResults = Boolean(operation.scriptText); + const { clearSelections } = useFileSelection(); + + useEffect(() => { + registerCustomWorkbenchView({ + id: VIEW_ID, + workbenchId: WORKBENCH_ID, + label: t('showJS.view.title', 'JavaScript'), + icon: viewIcon, + component: ({ data }) => , + }); + + return () => { + clearCustomWorkbenchViewData(VIEW_ID); + unregisterCustomWorkbenchView(VIEW_ID); + }; + }, [clearCustomWorkbenchViewData, registerCustomWorkbenchView, t, unregisterCustomWorkbenchView, viewIcon]); + + const lastShownRef = useRef(null); + + useEffect(() => { + if (operation.scriptText) { + setCustomWorkbenchViewData(VIEW_ID, { + scriptText: operation.scriptText, + downloadUrl: operation.downloadUrl, + downloadFilename: operation.downloadFilename, + }); + const marker = operation.scriptText.length; + const isNew = lastShownRef.current == null || marker !== lastShownRef.current; + if (isNew) { + lastShownRef.current = marker; + if (navigationState.selectedTool === 'showJS' && navigationState.workbench !== WORKBENCH_ID) { + navigationActions.setWorkbench(WORKBENCH_ID); + } + } + } else { + clearCustomWorkbenchViewData(VIEW_ID); + lastShownRef.current = null; + } + }, [ + clearCustomWorkbenchViewData, + navigationActions, + navigationState.selectedTool, + navigationState.workbench, + operation.scriptText, + setCustomWorkbenchViewData, + ]); + + useEffect(() => { + if ((base.selectedFiles?.length ?? 0) === 0) { + try { base.operation.resetResults(); } catch { /* noop */ } + try { clearCustomWorkbenchViewData(VIEW_ID); } catch { /* noop */ } + if (navigationState.workbench === WORKBENCH_ID) { + try { navigationActions.setWorkbench('fileEditor'); } catch { /* noop */ } + } + lastShownRef.current = null; + } + }, [ + base.selectedFiles?.length, + base.operation, + clearCustomWorkbenchViewData, + navigationActions, + navigationState.workbench, + ]); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: false, + }, + steps: [], + executeButton: { + text: hasResults ? t('back', 'Back') : t('showJS.submit', 'Extract JavaScript'), + loadingText: t('loading', 'Loading...'), + onClick: hasResults + ? async () => { + // Clear results and deselect files so user can pick another file + try { + await base.operation.resetResults(); + } catch { /* noop */ } + try { + clearSelections(); + } catch { /* noop */ } + // Close the custom JS view and send user back to file manager to pick another file + try { + clearCustomWorkbenchViewData(VIEW_ID); + } catch { /* noop */ } + try { + navigationActions.setWorkbench('fileEditor'); + } catch { /* noop */ } + } + : base.handleExecute, + disabled: hasResults + ? false + : ( + !base.hasFiles || + (base.selectedFiles?.length ?? 0) !== 1 || + base.operation.isLoading || + base.endpointLoading || + base.endpointEnabled === false + ), + isVisible: true, + }, + review: { + isVisible: hasResults, + operation: base.operation, + title: t('showJS.results', 'Result'), + onUndo: undefined, + }, + }); +}; + +const ShowJSTool = ShowJS as ToolComponent; +ShowJSTool.tool = () => useShowJSOperation; +ShowJSTool.getDefaultParameters = () => ({ ...defaultParameters }); + +export default ShowJSTool; + +