diff --git a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx index 432fb41c9..3d9149dbe 100644 --- a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx +++ b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx @@ -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')) { diff --git a/frontend/src/core/components/tools/showJS/ShowJSView.css b/frontend/src/core/components/tools/showJS/ShowJSView.css index 8673e60e2..5024d2c57 100644 --- a/frontend/src/core/components/tools/showJS/ShowJSView.css +++ b/frontend/src/core/components/tools/showJS/ShowJSView.css @@ -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; +} + diff --git a/frontend/src/core/components/tools/showJS/ShowJSView.tsx b/frontend/src/core/components/tools/showJS/ShowJSView.tsx index d82582ad8..8cdc340b0 100644 --- a/frontend/src/core/components/tools/showJS/ShowJSView.tsx +++ b/frontend/src/core/components/tools/showJS/ShowJSView.tsx @@ -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 = ({ data }) => { const scrollAreaInnerRef = useRef(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 { - 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([]); + const [lines, setLines] = useState([]); const [blocks, setBlocks] = useState>([]); const [collapsed, setCollapsed] = useState>(new Set()); @@ -143,7 +65,7 @@ const ShowJSView: React.FC = ({ data }) => { setLines(tokenizeToLines(src)); setBlocks(computeBlocks(src)); setCollapsed(new Set()); - }, [text, tokenizeToLines, computeBlocks]); + }, [text]); const startToEnd = useMemo(() => { const m = new Map(); @@ -175,18 +97,7 @@ const ShowJSView: React.FC = ({ 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 = ({ data }) => { }, [active, matches, startToEnd, collapsed]); return ( - - -
- + + +
+ setQuery(e.currentTarget.value)} size="xs" placeholder={t('search.placeholder', 'Enter search term...')} - style={{ width: 220 }} + className="showjs-search-input" /> {matches.length ? `${active + 1}/${matches.length}` : '0/0'} @@ -252,15 +138,11 @@ const ShowJSView: React.FC = ({ data }) => { - +
- -
+ +
= JS_KEYWORDS): ShowJsToken[][] { + const lines: ShowJsToken[][] = []; + let current: ShowJsToken[] = []; + let i = 0; + let inBlockCom = false; + let inLineCom = false; + let inString: '"' | "'" | '`' | null = null; + let escaped = false; + + const push = (type: ShowJsTokenType, s: string) => { + if (s) { + current.push({ type, text: s }); + } + }; + + // Named actions for readability + const advance = (n: number = 1) => { i += n; }; + const handleNewline = () => { + lines.push(current); + current = []; + inLineCom = false; + advance(); + }; + const handleInLineCommentChar = (ch: string) => { + push('com', ch); + advance(); + }; + const handleBlockCommentEnd = () => { + push('com', '*/'); + inBlockCom = false; + advance(2); + }; + const handleInBlockCommentChar = (ch: string) => { + push('com', ch); + advance(); + }; + const handleInStringChar = (ch: string) => { + push('str', ch); + if (!escaped) { + const isEscape = ch === '\\'; + const isStringClose = ch === inString; + if (isEscape) { + escaped = true; + } else if (isStringClose) { + inString = null; + } + } else { + escaped = false; + } + advance(); + }; + const startLineComment = () => { + push('com', '//'); + inLineCom = true; + advance(2); + }; + const startBlockComment = () => { + push('com', '/*'); + inBlockCom = true; + advance(2); + }; + const startString = (ch: '"' | "'" | '`') => { + inString = ch; + push('str', ch); + advance(); + }; + const pushNumberToken = () => { + let j = i + 1; + const isNumberContinuation = (c: string) => /[0-9._xobA-Fa-f]/.test(c); + while (j < src.length && isNumberContinuation(src[j])) { + j++; + } + push('num', src.slice(i, j)); + i = j; + }; + const pushIdentifierToken = () => { + let j = i + 1; + const isIdentContinuation = (c: string) => /[A-Za-z0-9_$]/.test(c); + while (j < src.length && isIdentContinuation(src[j])) { + j++; + } + const id = src.slice(i, j); + const isKeyword = keywords.has(id); + push(isKeyword ? 'kw' : 'plain', id); + i = j; + }; + const pushPlainChar = (ch: string) => { + push('plain', ch); + advance(); + }; + + while (i < src.length) { + const ch = src[i]; + const next = src[i + 1]; + + // Named conditions + const isNewline = ch === '\n'; + const isLineCommentStart = ch === '/' && next === '/'; + const isBlockCommentStart = ch === '/' && next === '*'; + const isStringDelimiter = ch === '\'' || ch === '"' || ch === '`'; + const isDigit = /[0-9]/.test(ch); + const isIdentifierStart = /[A-Za-z_$]/.test(ch); + + if (isNewline) { + handleNewline(); + continue; + } + + if (inLineCom) { + handleInLineCommentChar(ch); + continue; + } + + if (inBlockCom) { + const isBlockCommentEnd = ch === '*' && next === '/'; + if (isBlockCommentEnd) { + handleBlockCommentEnd(); + continue; + } + handleInBlockCommentChar(ch); + continue; + } + + if (inString) { + handleInStringChar(ch); + continue; + } + + if (isLineCommentStart) { + startLineComment(); + continue; + } + + if (isBlockCommentStart) { + startBlockComment(); + continue; + } + + if (isStringDelimiter) { + startString(ch as '"' | "'" | '`'); + continue; + } + + if (isDigit) { + pushNumberToken(); + continue; + } + + if (isIdentifierStart) { + pushIdentifierToken(); + continue; + } + + pushPlainChar(ch); + } + + lines.push(current); + return lines; +} + +export function computeBlocks(src: string): Array<{ start: number; end: number }> { + const res: Array<{ start: number; end: number }> = []; + let i = 0; + let line = 0; + let inBlock = false; + let inLine = false; + let str: '"' | "'" | '`' | null = null; + let esc = false; + const stack: number[] = []; + + // Actions + const advance = (n: number = 1) => { i += n; }; + const handleNewline = () => { + line++; + inLine = false; + advance(); + }; + const startLineComment = () => { + inLine = true; + advance(2); + }; + const startBlockComment = () => { + inBlock = true; + advance(2); + }; + const endBlockComment = () => { + inBlock = false; + advance(2); + }; + const startString = (delim: '"' | "'" | '`') => { + str = delim; + advance(); + }; + const handleStringChar = (ch: string) => { + if (!esc) { + const isEscape = ch === '\\'; + const isClose = ch === str; + if (isEscape) { + esc = true; + } else if (isClose) { + str = null; + } + } else { + esc = false; + } + advance(); + }; + const pushOpenBrace = () => { + stack.push(line); + advance(); + }; + const handleCloseBrace = () => { + const s = stack.pop(); + if (s != null && line > s) { + res.push({ start: s, end: line }); + } + advance(); + }; + + while (i < src.length) { + const ch = src[i]; + const nx = src[i + 1]; + + // Conditions + const isNewline = ch === '\n'; + const isLineCommentStart = ch === '/' && nx === '/'; + const isBlockCommentStart = ch === '/' && nx === '*'; + const isBlockCommentEnd = ch === '*' && nx === '/'; + const isStringDelimiter = ch === '\'' || ch === '"' || ch === '`'; + const isOpenBrace = ch === '{'; + const isCloseBrace = ch === '}'; + + if (isNewline) { + handleNewline(); + continue; + } + if (inLine) { + advance(); + continue; + } + if (inBlock) { + if (isBlockCommentEnd) { + endBlockComment(); + } else { + advance(); + } + continue; + } + if (str) { + handleStringChar(ch); + continue; + } + if (isLineCommentStart) { + startLineComment(); + continue; + } + if (isBlockCommentStart) { + startBlockComment(); + continue; + } + if (isStringDelimiter) { + startString(ch as '"' | "'" | '`'); + continue; + } + if (isOpenBrace) { + pushOpenBrace(); + continue; + } + if (isCloseBrace) { + handleCloseBrace(); + continue; + } + advance(); + } + return res; +} + +export function computeSearchMatches(lines: ShowJsToken[][], query: string): Array<{ line: number; start: number; end: number }> { + if (!query) { + return []; + } + const q = query.toLowerCase(); + const list: Array<{ line: number; start: number; end: number }> = []; + lines.forEach((toks, ln) => { + const raw = toks.map(t => t.text).join(''); + let idx = 0; + while (true) { + const pos = raw.toLowerCase().indexOf(q, idx); + if (pos === -1) { + break; + } + list.push({ + line: ln, + start: pos, + end: pos + q.length + }); + idx = pos + Math.max(1, q.length); + } + }); + return list; +} + +export async function copyTextToClipboard(text: string, fallbackElement?: HTMLElement | null): Promise { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + await navigator.clipboard.writeText(text || ''); + return true; + } + } catch { + // fall through to fallback + } + if (typeof document === 'undefined' || !fallbackElement) return false; + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(fallbackElement); + selection?.removeAllRanges(); + selection?.addRange(range); + try { + document.execCommand('copy'); + return true; + } finally { + selection?.removeAllRanges(); + } +} + +export function triggerDownload(url: string, filename: string): void { + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +