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:
Ettore Di Giacinto
2026-06-18 15:28:11 +00:00
parent a753f82823
commit a5a4701f05
3 changed files with 96 additions and 9 deletions

View File

@@ -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;

View File

@@ -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">

View File

@@ -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'