clean up code and break into seperate functoins

This commit is contained in:
EthanHealy01
2025-11-12 14:14:23 +00:00
parent 985253e40b
commit 56e420e583
4 changed files with 424 additions and 158 deletions

View File

@@ -3,14 +3,10 @@ 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;
function getExtension(name: string): string {
const lastDot = name.lastIndexOf('.');
return lastDot >= 0 ? name.slice(lastDot + 1).toLowerCase() : '';
}
/**
* Returns an appropriate file type icon for the provided file.
* - Uses the real file type and extension to decide the icon.
@@ -19,7 +15,7 @@ function getExtension(name: string): string {
export function getFileTypeIcon(file: FileLike, size: number | string = '2rem'): React.ReactElement {
const name = (file?.name ?? '').toLowerCase();
const mime = (file?.type ?? '').toLowerCase();
const ext = getExtension(name);
const ext = detectFileExtension(name);
// JavaScript
if (ext === 'js' || mime.includes('javascript')) {

View File

@@ -67,4 +67,57 @@
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;
}

View File

@@ -7,6 +7,15 @@ 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;
@@ -36,105 +45,18 @@ const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
const scrollAreaInnerRef = useRef<HTMLDivElement | null>(null);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text || '');
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
// Fallback: try selection copy
const el = codeRef.current;
if (!el) return;
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
selection?.removeAllRanges();
selection?.addRange(range);
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} finally {
selection?.removeAllRanges();
}
}
const ok = await copyTextToClipboard(text || '', codeRef.current);
if (!ok) return;
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}, [text]);
const handleDownload = useCallback(() => {
if (!downloadUrl) return;
const a = document.createElement('a');
a.href = downloadUrl;
a.download = downloadFilename || 'extracted.js';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
triggerDownload(downloadUrl, downloadFilename || 'extracted.js');
}, [downloadUrl, downloadFilename]);
// Tokenize to lines for highlight, folding and search
type TokenType = 'kw' | 'str' | 'num' | 'com' | 'plain';
type Token = { type: TokenType; text: string };
const KEYWORDS = useMemo(() => 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'
]), []);
const tokenizeToLines = useCallback((src: string): Token[][] => {
const lines: Token[][] = [];
let current: Token[] = [];
let i = 0;
let inBlockCom = false;
let inLineCom = false;
let inString: '"' | "'" | '`' | null = null;
let escaped = false;
const push = (type: TokenType, s: string) => { if (s) current.push({ type, text: s }); };
while (i < src.length) {
const ch = src[i];
const next = src[i + 1];
if (ch === '\n') { lines.push(current); current = []; inLineCom = false; i++; continue; }
if (inLineCom) { push('com', ch); i++; continue; }
if (inBlockCom) {
if (ch === '*' && next === '/') { push('com', '*/'); inBlockCom = false; i += 2; continue; }
push('com', ch); i++; continue;
}
if (inString) {
push('str', ch);
if (!escaped) {
if (ch === '\\') escaped = true;
else if (ch === inString) inString = null;
} else { escaped = false; }
i++; continue;
}
if (ch === '/' && next === '/') { push('com', '//'); inLineCom = true; i += 2; continue; }
if (ch === '/' && next === '*') { push('com', '/*'); inBlockCom = true; i += 2; continue; }
if (ch === '\'' || ch === '"' || ch === '`') { inString = ch; push('str', ch); i++; continue; }
if (/[0-9]/.test(ch)) { let j=i+1; while (j<src.length && /[0-9._xobA-Fa-f]/.test(src[j])) j++; push('num', src.slice(i,j)); i=j; continue; }
if (/[A-Za-z_$]/.test(ch)) { let j=i+1; while (j<src.length && /[A-Za-z0-9_$]/.test(src[j])) j++; const id=src.slice(i,j); push(KEYWORDS.has(id)?'kw':'plain', id); i=j; continue; }
push('plain', ch); i++;
}
lines.push(current);
return lines;
}, [KEYWORDS]);
const computeBlocks = useCallback((src: string) => {
const res: Array<{ start: number; end: number }> = [];
let i=0, line=0;
let inBlock=false, inLine=false, str: '"' | "'" | '`' | null = null, esc=false;
const stack: number[] = [];
while (i < src.length) {
const ch = src[i], nx = src[i+1];
if (ch === '\n') { line++; inLine=false; i++; continue; }
if (inLine) { i++; continue; }
if (inBlock) { if (ch==='*'&&nx=== '/') { inBlock=false; i+=2; } else i++; continue; }
if (str) { if (!esc) { if (ch==='\\') esc=true; else if (ch===str) str=null; } else esc=false; i++; continue; }
if (ch==='/'&&nx==='/' ){ inLine=true; i+=2; continue; }
if (ch==='/'&&nx==='*' ){ inBlock=true; i+=2; continue; }
if (ch=== '\'' || ch=== '"' || ch==='`'){ str=ch; i++; continue; }
if (ch === '{') { stack.push(line); i++; continue; }
if (ch === '}') { const s = stack.pop(); if (s!=null && line>s) res.push({ start:s, end:line }); i++; continue; }
i++;
}
return res;
}, []);
const [lines, setLines] = useState<Token[][]>([]);
const [lines, setLines] = useState<ShowJsToken[][]>([]);
const [blocks, setBlocks] = useState<Array<{ start: number; end: number }>>([]);
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
@@ -143,7 +65,7 @@ const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
setLines(tokenizeToLines(src));
setBlocks(computeBlocks(src));
setCollapsed(new Set());
}, [text, tokenizeToLines, computeBlocks]);
}, [text]);
const startToEnd = useMemo(() => {
const m = new Map<number, number>();
@@ -175,18 +97,7 @@ const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
useEffect(() => {
if (!query) { setMatches([]); setActive(0); 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);
}
});
const list = computeSearchMatches(lines, query);
setMatches(list);
setActive(list.length ? 0 : 0);
}, [query, lines]);
@@ -206,41 +117,16 @@ const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
}, [active, matches, startToEnd, collapsed]);
return (
<Stack gap="sm" p="sm" style={{ height: '100%', marginLeft: '1rem', marginRight: '1rem' }}>
<Box
style={{
position: 'relative',
height: '100%',
minHeight: 360,
border: '1px solid var(--mantine-color-gray-4)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--right-rail-bg)',
}}
>
<div
style={{
position: 'sticky',
top: 8,
right: 8,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
zIndex: 0,
padding: 8,
background: 'transparent',
marginBottom: '10px',
marginLeft: '6px',
pointerEvents: 'none',
}}
>
<Group gap="xs" align="center" style={{ pointerEvents: 'auto' }}>
<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...')}
style={{ width: 220 }}
className="showjs-search-input"
/>
<Text size="xs" c="dimmed">
{matches.length ? `${active + 1}/${matches.length}` : '0/0'}
@@ -252,15 +138,11 @@ const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
<ArrowDownwardRoundedIcon fontSize="small" />
</ActionIcon>
</Group>
<Group gap="xs" align="center" style={{ pointerEvents: 'auto' }}>
<Group gap="xs" align="center" className="showjs-toolbar-controls">
<Button
size="xs"
variant="subtle"
style={{
background: 'transparent',
border: '1px solid currentColor',
color: 'var(--mantine-color-blue-5)'
}}
className="showjs-outline-button"
onClick={handleDownload}
disabled={!downloadUrl}
leftSection={<DownloadRoundedIcon fontSize="small" />}
@@ -270,11 +152,7 @@ const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
<Button
size="xs"
variant="subtle"
style={{
background: 'transparent',
border: '1px solid currentColor',
color: 'var(--mantine-color-blue-5)'
}}
className="showjs-outline-button"
onClick={handleCopy}
leftSection={<ContentCopyRoundedIcon fontSize="small" />}
>
@@ -282,11 +160,8 @@ const ShowJSView: React.FC<ShowJSViewProps> = ({ data }) => {
</Button>
</Group>
</div>
<ScrollArea
style={{ height: 'calc(100vh - 220px)' }}
offsetScrollbars
>
<div ref={scrollAreaInnerRef} style={{ padding: '40px 24px 24px 24px' }}>
<ScrollArea className="showjs-scrollarea" offsetScrollbars>
<div ref={scrollAreaInnerRef} className="showjs-inner">
<div
ref={codeRef}
className="showjs-code"

View File

@@ -0,0 +1,342 @@
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);
}