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:
EthanHealy01
2025-11-12 15:02:43 +00:00
committed by GitHub
parent a5e2b54274
commit c8615518a6
15 changed files with 1281 additions and 43 deletions
@@ -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 (
@@ -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;
}
@@ -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;
@@ -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);
}