mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-18 21:58:58 -04:00
feat(ui): canvas drag-to-resize + slide-in, fix hooks order, typed download
Canvas was a fixed pane; make it a workbench: - Drag the panel's left edge to resize (clamped 360px..75vw), persisted to localStorage, double-click to reset; hidden and full-width on narrow screens. - Slide-in/fade on open via canvasSlideIn (honored by reduced-motion). - Fix a rules-of-hooks bug: the `if (!current) return null` early return sat above useEffect, so the hook count changed when artifacts emptied. All hooks now run unconditionally before the guard. - Downloads use the artifact language's real extension + MIME (a Python artifact saves as .py, not .txt) via extensionForLanguage. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -5219,14 +5219,41 @@ select.input {
|
||||
|
||||
/* Canvas panel */
|
||||
.canvas-panel {
|
||||
position: relative;
|
||||
width: 45%;
|
||||
max-width: 720px;
|
||||
flex-shrink: 1;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-primary);
|
||||
overflow: hidden;
|
||||
animation: canvasSlideIn var(--duration-reveal) var(--ease-reveal) both;
|
||||
}
|
||||
@keyframes canvasSlideIn {
|
||||
from { opacity: 0; transform: translateX(24px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
/* Drag-to-resize handle on the panel's left edge. */
|
||||
.canvas-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -3px;
|
||||
width: 7px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
transition: background var(--duration-fast) var(--ease-default);
|
||||
}
|
||||
.canvas-resize-handle:hover,
|
||||
.canvas-resize-handle:active {
|
||||
background: var(--color-primary-light);
|
||||
}
|
||||
/* On narrow screens the canvas takes the full width; no edge-drag resize. */
|
||||
@media (max-width: 1024px) {
|
||||
.canvas-resize-handle { display: none; }
|
||||
.canvas-panel { width: 100% !important; max-width: none !important; }
|
||||
}
|
||||
.canvas-panel-header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,29 +1,64 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { renderMarkdown } from '../utils/markdown'
|
||||
import { getArtifactIcon } from '../utils/artifacts'
|
||||
import { getArtifactIcon, extensionForLanguage } from '../utils/artifacts'
|
||||
import { safeHref } from '../utils/url'
|
||||
import { copyToClipboard } from '../utils/clipboard'
|
||||
import DOMPurify from 'dompurify'
|
||||
import hljs from '../utils/hljs'
|
||||
|
||||
const WIDTH_KEY = 'localai_canvas_width'
|
||||
const MIME_BY_EXT = { html: 'text/html', svg: 'image/svg+xml', json: 'application/json', css: 'text/css' }
|
||||
|
||||
export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }) {
|
||||
const [showPreview, setShowPreview] = useState(true)
|
||||
const [copySuccess, setCopySuccess] = useState(false)
|
||||
// Persisted drag-to-resize width (px). null = use the CSS default (45%).
|
||||
const [width, setWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem(WIDTH_KEY); return v ? Number(v) : null } catch { return null }
|
||||
})
|
||||
const codeRef = useRef(null)
|
||||
const panelRef = useRef(null)
|
||||
|
||||
const current = artifacts.find(a => a.id === selectedId) || artifacts[0]
|
||||
if (!current) return null
|
||||
|
||||
const hasPreview = current.type === 'code' && ['html', 'svg', 'md', 'markdown'].includes(current.language)
|
||||
const hasPreview = !!current && current.type === 'code' && ['html', 'svg', 'md', 'markdown'].includes(current.language)
|
||||
|
||||
// All hooks must run unconditionally (no early return above them).
|
||||
useEffect(() => {
|
||||
if (codeRef.current && !showPreview && current.type === 'code') {
|
||||
if (codeRef.current && !showPreview && current?.type === 'code') {
|
||||
codeRef.current.querySelectorAll('pre code').forEach(block => {
|
||||
hljs.highlightElement(block)
|
||||
})
|
||||
}
|
||||
}, [current, showPreview])
|
||||
|
||||
// Drag the left edge to resize; clamp to a sane range; persist on release.
|
||||
const startResize = (e) => {
|
||||
e.preventDefault()
|
||||
const startX = e.clientX
|
||||
const startW = panelRef.current?.offsetWidth || 0
|
||||
const maxW = Math.round(window.innerWidth * 0.75)
|
||||
const onMove = (ev) => {
|
||||
const next = Math.min(Math.max(startW + (startX - ev.clientX), 360), maxW)
|
||||
setWidth(next)
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
document.body.style.userSelect = ''
|
||||
try { localStorage.setItem(WIDTH_KEY, String(panelRef.current?.offsetWidth || '')) } catch { /* ignore */ }
|
||||
}
|
||||
document.body.style.userSelect = 'none'
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
const resetWidth = () => {
|
||||
setWidth(null)
|
||||
try { localStorage.removeItem(WIDTH_KEY) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (!current) return null
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = current.code || current.url || ''
|
||||
const ok = await copyToClipboard(text)
|
||||
@@ -35,11 +70,15 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
|
||||
|
||||
const handleDownload = () => {
|
||||
if (current.type === 'code') {
|
||||
const blob = new Blob([current.code], { type: 'text/plain' })
|
||||
const ext = extensionForLanguage(current.language)
|
||||
const blob = new Blob([current.code], { type: MIME_BY_EXT[ext] || 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = current.title || 'download.txt'
|
||||
// Keep a title that already has an extension; otherwise slugify + add ext.
|
||||
a.download = current.title && /\.[a-z0-9]+$/i.test(current.title)
|
||||
? current.title
|
||||
: `${(current.title || 'artifact').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'artifact'}.${ext}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} else if (current.url) {
|
||||
@@ -110,7 +149,16 @@ export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="canvas-panel">
|
||||
<div className="canvas-panel" ref={panelRef} style={width ? { width: `${width}px`, maxWidth: 'none' } : undefined}>
|
||||
<div
|
||||
className="canvas-resize-handle"
|
||||
onMouseDown={startResize}
|
||||
onDoubleClick={resetWidth}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize canvas (double-click to reset)"
|
||||
title="Drag to resize, double-click to reset"
|
||||
/>
|
||||
<div className="canvas-panel-header">
|
||||
<span className="canvas-panel-title">{current.title || 'Artifact'}</span>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onClose} title="Close canvas">
|
||||
|
||||
12
core/http/react-ui/src/utils/artifacts.js
vendored
12
core/http/react-ui/src/utils/artifacts.js
vendored
@@ -100,6 +100,18 @@ function guessTitle(lang, index) {
|
||||
return index > 0 && extMap[lang] ? base.replace('.', `-${index}.`) : base
|
||||
}
|
||||
|
||||
const LANG_EXT = {
|
||||
html: 'html', javascript: 'js', js: 'js', typescript: 'ts', ts: 'ts',
|
||||
jsx: 'jsx', tsx: 'tsx', python: 'py', py: 'py', css: 'css', svg: 'svg',
|
||||
json: 'json', yaml: 'yaml', yml: 'yaml', go: 'go', rust: 'rs', java: 'java',
|
||||
markdown: 'md', md: 'md', bash: 'sh', sh: 'sh', sql: 'sql',
|
||||
}
|
||||
|
||||
// File extension for a code artifact's language, for download filenames.
|
||||
export function extensionForLanguage(lang) {
|
||||
return LANG_EXT[(lang || '').toLowerCase()] || 'txt'
|
||||
}
|
||||
|
||||
export function getArtifactIcon(type, language) {
|
||||
if (type === 'image') return 'fa-image'
|
||||
if (type === 'pdf') return 'fa-file-pdf'
|
||||
|
||||
Reference in New Issue
Block a user