mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
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.
This commit is contained in:
parent
a5e2b54274
commit
c8615518a6
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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<number>(() => {
|
||||
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
|
||||
|
||||
@ -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<DocumentThumbnailProps> = ({
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
|
||||
<PrivateContent>
|
||||
<PictureAsPdfIcon
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
/>
|
||||
{getFileTypeIcon(file)}
|
||||
</PrivateContent>
|
||||
</Center>
|
||||
{children}
|
||||
|
||||
@ -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 <JavascriptIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
|
||||
}
|
||||
|
||||
// PDF
|
||||
if (ext === "pdf" || mime === "application/pdf") {
|
||||
return <PictureAsPdfIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
|
||||
}
|
||||
|
||||
// Fallback generic
|
||||
return <InsertDriveFileIcon style={{ fontSize: size, color: "var(--mantine-color-gray-6)" }} />;
|
||||
}
|
||||
@ -14,7 +14,7 @@ export interface ReviewToolStepProps<TParams = unknown> {
|
||||
operation: ToolOperationHook<TParams>;
|
||||
title?: string;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
onUndo?: () => void;
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
}
|
||||
@ -26,14 +26,14 @@ function ReviewStepContent<TParams = unknown>({
|
||||
}: {
|
||||
operation: ToolOperationHook<TParams>;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
onUndo?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stepRef = useRef<HTMLDivElement>(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<TParams = unknown>({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
|
||||
<Button
|
||||
leftSection={<UndoIcon />}
|
||||
variant="outline"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
onClick={handleUndo}
|
||||
fullWidth
|
||||
>
|
||||
{t("undo", "Undo")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{onUndo && (
|
||||
<Tooltip content={t("undoOperationTooltip", "Click to undo the last operation and restore the original files")}>
|
||||
<Button
|
||||
leftSection={<UndoIcon />}
|
||||
variant="outline"
|
||||
color="var(--mantine-color-gray-6)"
|
||||
onClick={handleUndo}
|
||||
fullWidth
|
||||
>
|
||||
{t("undo", "Undo")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{operation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
@ -104,7 +106,17 @@ function ReviewStepContent<TParams = unknown>({
|
||||
}
|
||||
|
||||
export function createReviewToolStep<TParams = unknown>(
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||
createStep: (
|
||||
title: string,
|
||||
props: {
|
||||
isVisible?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
_excludeFromCount?: boolean;
|
||||
_noPadding?: boolean;
|
||||
},
|
||||
children?: React.ReactNode
|
||||
) => React.ReactElement,
|
||||
props: ReviewToolStepProps<TParams>
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -5,6 +5,7 @@ import OperationButton from '@app/components/tools/shared/OperationButton';
|
||||
import { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from '@app/components/tools/shared/ToolWorkflowTitle';
|
||||
import { StirlingFile } from '@app/types/fileContext';
|
||||
import type { TooltipTip } from '@app/types/tips';
|
||||
|
||||
export interface FilesStepConfig {
|
||||
selectedFiles: StirlingFile[];
|
||||
@ -22,7 +23,7 @@ export interface MiddleStepConfig {
|
||||
content: React.ReactNode;
|
||||
tooltip?: {
|
||||
content?: React.ReactNode;
|
||||
tips?: any[];
|
||||
tips?: TooltipTip[];
|
||||
header?: {
|
||||
title: string;
|
||||
logo?: React.ReactNode;
|
||||
@ -39,25 +40,25 @@ export interface ExecuteButtonConfig {
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export interface ReviewStepConfig {
|
||||
export interface ReviewStepConfig<TParams = unknown> {
|
||||
isVisible: boolean;
|
||||
operation: ToolOperationHook<any>;
|
||||
operation: ToolOperationHook<TParams>;
|
||||
title: string;
|
||||
onFileClick?: (file: File) => void;
|
||||
onUndo: () => void;
|
||||
onUndo?: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export interface TitleConfig extends ToolWorkflowTitleProps {}
|
||||
|
||||
export interface ToolFlowConfig {
|
||||
export interface ToolFlowConfig<TParams = unknown> {
|
||||
title?: TitleConfig;
|
||||
files: FilesStepConfig;
|
||||
steps: MiddleStepConfig[];
|
||||
// Optional preview content rendered between steps and the execute button
|
||||
preview?: React.ReactNode;
|
||||
executeButton?: ExecuteButtonConfig;
|
||||
review: ReviewStepConfig;
|
||||
review: ReviewStepConfig<TParams>;
|
||||
forceStepNumbers?: boolean;
|
||||
}
|
||||
|
||||
@ -65,7 +66,7 @@ export interface ToolFlowConfig {
|
||||
* 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.
|
||||
*/
|
||||
export function createToolFlow(config: ToolFlowConfig) {
|
||||
export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams>) {
|
||||
const steps = createToolSteps();
|
||||
|
||||
return (
|
||||
|
||||
131
frontend/src/core/components/tools/showJS/ShowJSView.css
Normal file
131
frontend/src/core/components/tools/showJS/ShowJSView.css
Normal file
@ -0,0 +1,131 @@
|
||||
.showjs-code {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
tab-size: 2;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tok-kw {
|
||||
color: var(--code-kw-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tok-str {
|
||||
color: var(--code-str-color);
|
||||
}
|
||||
.tok-num {
|
||||
color: var(--code-num-color);
|
||||
}
|
||||
.tok-com {
|
||||
color: var(--code-com-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-gutter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 64px;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
.line-number {
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
}
|
||||
.fold-toggle {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.fold-collapsed {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.fold-placeholder {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
}
|
||||
.code-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.collapsed-indicator {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
padding-left: 8px;
|
||||
}
|
||||
.collapsed-inline {
|
||||
color: var(--text-muted);
|
||||
margin-left: 6px;
|
||||
}
|
||||
.search-hit {
|
||||
background: rgba(255, 235, 59, 0.4); /* yellow highlight */
|
||||
border-radius: 2px;
|
||||
}
|
||||
.search-hit-active {
|
||||
background: rgba(33, 150, 243, 0.4); /* active blue */
|
||||
}
|
||||
|
||||
.showjs-root {
|
||||
height: 100%;
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.showjs-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 360px;
|
||||
border: 1px solid var(--mantine-color-gray-4);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--right-rail-bg);
|
||||
}
|
||||
|
||||
.showjs-toolbar {
|
||||
position: sticky;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 0;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
margin-bottom: 10px;
|
||||
margin-left: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.showjs-toolbar-controls {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.showjs-search-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.showjs-outline-button {
|
||||
background: transparent;
|
||||
border: 1px solid currentColor;
|
||||
color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.showjs-scrollarea {
|
||||
height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.showjs-inner {
|
||||
padding: 40px 24px 24px 24px;
|
||||
}
|
||||
305
frontend/src/core/components/tools/showJS/ShowJSView.tsx
Normal file
305
frontend/src/core/components/tools/showJS/ShowJSView.tsx
Normal file
@ -0,0 +1,305 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActionIcon, Box, Button, Group, Stack, Text, ScrollArea, TextInput } from "@mantine/core";
|
||||
import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded";
|
||||
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
|
||||
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
|
||||
import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
|
||||
import "@app/components/tools/showJS/ShowJSView.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
tokenizeToLines,
|
||||
computeBlocks,
|
||||
computeSearchMatches,
|
||||
copyTextToClipboard,
|
||||
triggerDownload,
|
||||
type ShowJsToken,
|
||||
} from "@app/components/tools/showJS/utils";
|
||||
|
||||
interface ScriptData {
|
||||
scriptText: string;
|
||||
downloadUrl?: string | null;
|
||||
downloadFilename?: string | null;
|
||||
}
|
||||
|
||||
interface ShowJSViewProps {
|
||||
data: string | ScriptData;
|
||||
}
|
||||
|
||||
const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
const text = useMemo(() => {
|
||||
if (typeof data === "string") return data;
|
||||
return data?.scriptText ?? "";
|
||||
}, [data]);
|
||||
const downloadUrl = useMemo(() => {
|
||||
if (typeof data === "string") return null;
|
||||
return data?.downloadUrl ?? null;
|
||||
}, [data]);
|
||||
const downloadFilename = useMemo(() => {
|
||||
if (typeof data === "string") return null;
|
||||
return data?.downloadFilename ?? null;
|
||||
}, [data]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const codeRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollAreaInnerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const ok = await copyTextToClipboard(text || "", codeRef.current);
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
}, [text]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!downloadUrl) return;
|
||||
triggerDownload(downloadUrl, downloadFilename || "extracted.js");
|
||||
}, [downloadUrl, downloadFilename]);
|
||||
|
||||
const [lines, setLines] = useState<ShowJsToken[][]>([]);
|
||||
const [blocks, setBlocks] = useState<Array<{ start: number; end: number }>>([]);
|
||||
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const src = text || "";
|
||||
setLines(tokenizeToLines(src));
|
||||
setBlocks(computeBlocks(src));
|
||||
setCollapsed(new Set());
|
||||
}, [text]);
|
||||
|
||||
const startToEnd = useMemo(() => {
|
||||
const m = new Map<number, number>();
|
||||
for (const b of blocks) if (!m.has(b.start)) m.set(b.start, b.end);
|
||||
return m;
|
||||
}, [blocks]);
|
||||
|
||||
const isHidden = useCallback(
|
||||
(ln: number) => {
|
||||
for (const s of collapsed) {
|
||||
const e = startToEnd.get(s);
|
||||
if (e != null && ln > s && ln <= e) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[collapsed, startToEnd],
|
||||
);
|
||||
|
||||
const toggleFold = (ln: number) => {
|
||||
if (!startToEnd.has(ln)) return;
|
||||
setCollapsed((prev) => {
|
||||
const n = new Set(prev);
|
||||
if (n.has(ln)) n.delete(ln);
|
||||
else n.add(ln);
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
// Search
|
||||
const [query, setQuery] = useState("");
|
||||
const [matches, setMatches] = useState<Array<{ line: number; start: number; end: number }>>([]);
|
||||
const [active, setActive] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query) {
|
||||
setMatches([]);
|
||||
setActive(0);
|
||||
return;
|
||||
}
|
||||
const list = computeSearchMatches(lines, query);
|
||||
setMatches(list);
|
||||
setActive(list.length ? 0 : 0);
|
||||
}, [query, lines]);
|
||||
|
||||
useEffect(() => {
|
||||
const m = matches[active];
|
||||
if (!m) return;
|
||||
for (const [s, e] of startToEnd.entries()) {
|
||||
if (m.line > s && m.line <= e && collapsed.has(s)) {
|
||||
setCollapsed((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(s);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (scrollAreaInnerRef.current) {
|
||||
const el = scrollAreaInnerRef.current.querySelector(`[data-code-line="${m.line}"]`) as HTMLElement | null;
|
||||
if (el) el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [active, matches, startToEnd, collapsed]);
|
||||
|
||||
return (
|
||||
<Stack gap="sm" p="sm" className="showjs-root">
|
||||
<Box className="showjs-container">
|
||||
<div className="showjs-toolbar">
|
||||
<Group gap="xs" align="center" className="showjs-toolbar-controls">
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
size="xs"
|
||||
placeholder={t("search.placeholder", "Enter search term...")}
|
||||
className="showjs-search-input"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{matches.length ? `${active + 1}/${matches.length}` : "0/0"}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
if (matches.length) setActive((p) => (p - 1 + matches.length) % matches.length);
|
||||
}}
|
||||
aria-label={t("common.previous", "Previous")}
|
||||
>
|
||||
<ArrowUpwardRoundedIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
if (matches.length) setActive((p) => (p + 1) % matches.length);
|
||||
}}
|
||||
aria-label={t("common.next", "Next")}
|
||||
>
|
||||
<ArrowDownwardRoundedIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Group gap="xs" align="center" className="showjs-toolbar-controls">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
className="showjs-outline-button"
|
||||
onClick={handleDownload}
|
||||
disabled={!downloadUrl}
|
||||
leftSection={<DownloadRoundedIcon fontSize="small" />}
|
||||
>
|
||||
{t("download", "Download")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
className="showjs-outline-button"
|
||||
onClick={handleCopy}
|
||||
leftSection={<ContentCopyRoundedIcon fontSize="small" />}
|
||||
>
|
||||
{copied ? t("common.copied", "Copied!") : t("common.copy", "Copy")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
<ScrollArea className="showjs-scrollarea" offsetScrollbars>
|
||||
<div ref={scrollAreaInnerRef} className="showjs-inner">
|
||||
<div ref={codeRef} className="showjs-code">
|
||||
{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(
|
||||
<span key={`t-${ln}-${ti}`} className={cls}>
|
||||
{textSeg}
|
||||
</span>,
|
||||
);
|
||||
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(
|
||||
<span key={`t-${ln}-${ti}`} className={cls}>
|
||||
{textSeg}
|
||||
</span>,
|
||||
);
|
||||
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(
|
||||
<span key={`t-${ln}-${ti}-b-${cursor}`} className={cls}>
|
||||
{beforeText}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
// 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(
|
||||
<span key={`t-${ln}-${ti}-h-${localStart}-${mi}`} className={hitCls}>
|
||||
{hitText}
|
||||
</span>,
|
||||
);
|
||||
cursor = localEnd;
|
||||
});
|
||||
|
||||
// tail after last match
|
||||
if (cursor < textSeg.length) {
|
||||
const tailText = textSeg.slice(cursor);
|
||||
const cls = tokenCls || undefined;
|
||||
content.push(
|
||||
<span key={`t-${ln}-${ti}-a-${cursor}`} className={cls}>
|
||||
{tailText}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
pos = tokenEnd;
|
||||
});
|
||||
return (
|
||||
<div key={`l-${ln}`} className="code-line" data-code-line={ln}>
|
||||
<div className="code-gutter">
|
||||
{end != null ? (
|
||||
<button
|
||||
className={`fold-toggle ${folded ? "fold-collapsed" : ""}`}
|
||||
onClick={() => toggleFold(ln)}
|
||||
aria-label={folded ? t("common.expand", "Expand") : t("common.collapse", "Collapse")}
|
||||
>
|
||||
{folded ? "▸" : "▾"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="fold-placeholder" />
|
||||
)}
|
||||
<span className="line-number">{ln + 1}</span>
|
||||
</div>
|
||||
<div className="code-content">
|
||||
{content}
|
||||
{folded && <span className="collapsed-inline">{"{...}"}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowJSView;
|
||||
382
frontend/src/core/components/tools/showJS/utils.ts
Normal file
382
frontend/src/core/components/tools/showJS/utils.ts
Normal file
@ -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<string> = 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<boolean> {
|
||||
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);
|
||||
}
|
||||
@ -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: <LocalIcon icon="javascript-rounded" width="1.5rem" height="1.5rem" />,
|
||||
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
|
||||
|
||||
137
frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts
Normal file
137
frontend/src/core/hooks/tools/showJS/useShowJSOperation.ts
Normal file
@ -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<ShowJSParameters> {
|
||||
scriptText: string | null;
|
||||
}
|
||||
|
||||
export const useShowJSOperation = (): ShowJSOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [downloadFilename, setDownloadFilename] = useState('');
|
||||
const [scriptText, setScriptText] = useState<string | null>(null);
|
||||
|
||||
const cancelRequested = useRef(false);
|
||||
const previousUrl = useRef<string | null>(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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
21
frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts
Normal file
21
frontend/src/core/hooks/tools/showJS/useShowJSParameters.ts
Normal file
@ -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<ShowJSParameters>;
|
||||
|
||||
export const useShowJSParameters = (): ShowJSParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'show-javascript',
|
||||
});
|
||||
};
|
||||
|
||||
@ -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);
|
||||
|
||||
150
frontend/src/core/tools/ShowJS.tsx
Normal file
150
frontend/src/core/tools/ShowJS.tsx
Normal file
@ -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(() => <CodeRoundedIcon fontSize="small" />, []);
|
||||
|
||||
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 }) => <ShowJSView data={data} />,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(VIEW_ID);
|
||||
unregisterCustomWorkbenchView(VIEW_ID);
|
||||
};
|
||||
}, [clearCustomWorkbenchViewData, registerCustomWorkbenchView, t, unregisterCustomWorkbenchView, viewIcon]);
|
||||
|
||||
const lastShownRef = useRef<number | null>(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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user