diff --git a/src/components/tiles/ArticleTile.jsx b/src/components/tiles/ArticleTile.jsx index 7044c6b..65b383a 100644 --- a/src/components/tiles/ArticleTile.jsx +++ b/src/components/tiles/ArticleTile.jsx @@ -1,3 +1,4 @@ +import { PureComponent } from 'react'; import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import Box from '@mui/material/Box'; import ButtonBase from '@mui/material/ButtonBase'; @@ -5,8 +6,205 @@ import Typography from '@mui/material/Typography'; import { Tile, tileBaseSx } from 'dashgui/tiles'; import { formatPrice } from '../../utils/price'; import { articleSku } from '../article/articleRoute'; -import ArticlePhoto from '../article/ArticlePhoto'; +import { decodeThumbHash } from '../article/ThumbHashImage'; import { BrandLabel } from '../brand'; +import { thumb } from '../../api'; + +const MIN_EDGE_STRETCH = 10; + +function drawContainedWithStretchedEdges( + ctx, + source, + srcW, + srcH, + dstW, + dstH, + minStretch = MIN_EDGE_STRETCH, +) { + ctx.clearRect(0, 0, dstW, dstH); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + const stretch = Math.min(minStretch, Math.max(0, Math.floor(Math.min(dstW, dstH) / 4))); + const innerW = dstW - stretch * 2; + const innerH = dstH - stretch * 2; + const scale = Math.min(innerW / srcW, innerH / srcH); + const fitW = srcW * scale; + const fitH = srcH * scale; + const offsetX = (dstW - fitW) / 2; + const offsetY = (dstH - fitH) / 2; + + const leftW = Math.ceil(offsetX); + const rightX = Math.floor(offsetX + fitW); + const rightW = dstW - rightX; + const topH = Math.ceil(offsetY); + const botY = Math.floor(offsetY + fitH); + const botH = dstH - botY; + + // Edge fill: a strongly blurred layer, crossfaded smoothly into a lightly + // blurred layer near the image (masked by a blurred rect, no visible seams). + // Rects overdraw past the canvas and under the center so blur never fades + // into transparency. + const maxBlur = Math.max(2, stretch * 0.6); + const drawEdges = (c, blur) => { + const pad = Math.ceil(blur * 3); + c.filter = `blur(${blur}px)`; + + // 1. Four corners (1x1 source border pixels stretched) + c.drawImage(source, 0, 0, 1, 1, -pad, -pad, leftW + pad * 2, topH + pad * 2); + c.drawImage(source, srcW - 1, 0, 1, 1, rightX - pad, -pad, rightW + pad * 2, topH + pad * 2); + c.drawImage(source, 0, srcH - 1, 1, 1, -pad, botY - pad, leftW + pad * 2, botH + pad * 2); + c.drawImage( + source, srcW - 1, srcH - 1, 1, 1, + rightX - pad, botY - pad, rightW + pad * 2, botH + pad * 2, + ); + + // 2. Four edge bars (stretched 1px border rows/columns) + c.drawImage(source, 0, 0, srcW, 1, offsetX, -pad, fitW, topH + pad * 2); + c.drawImage(source, 0, srcH - 1, srcW, 1, offsetX, botY - pad, fitW, botH + pad * 2); + c.drawImage(source, 0, 0, 1, srcH, -pad, offsetY, leftW + pad * 2, fitH); + c.drawImage(source, srcW - 1, 0, 1, srcH, rightX - pad, offsetY, rightW + pad * 2, fitH); + + c.filter = 'none'; + }; + + drawEdges(ctx, maxBlur); + + const light = document.createElement('canvas'); + light.width = dstW; + light.height = dstH; + const lctx = light.getContext('2d'); + if (lctx) { + drawEdges(lctx, Math.max(0.5, maxBlur * 0.15)); + // Mask: blurred rect around the image -> opaque near it, fading outward. + const grow = maxBlur * 0.5; + lctx.globalCompositeOperation = 'destination-in'; + lctx.filter = `blur(${maxBlur}px)`; + lctx.fillStyle = '#000'; + lctx.fillRect( + offsetX - grow, + offsetY - grow, + fitW + grow * 2, + fitH + grow * 2, + ); + lctx.filter = 'none'; + ctx.drawImage(light, 0, 0); + } + + // 3. Center image (aspect ratio preserved / contained) + ctx.drawImage(source, 0, 0, srcW, srcH, offsetX, offsetY, fitW, fitH); +} + +class ArticleTileBackdrop extends PureComponent { + canvasRef = null; + img = null; + observer = null; + + componentDidMount() { + this.loadImage(); + } + + componentDidUpdate(prevProps) { + if (prevProps.fullHash !== this.props.fullHash) { + this.loadImage(); + } + } + + componentWillUnmount() { + this.observer?.disconnect(); + this.img = null; + } + + loadImage() { + const { fullHash } = this.props; + if (fullHash) { + const img = new Image(); + img.onload = () => { + if (this.img === img) { + this.paint(); + } + }; + img.src = thumb.url(fullHash); + this.img = img; + if (img.complete && img.naturalWidth) { + this.paint(); + return; + } + } + this.paint(); + } + + setCanvas = (canvas) => { + this.canvasRef = canvas; + if (canvas && typeof ResizeObserver !== 'undefined') { + this.observer?.disconnect(); + this.observer = new ResizeObserver(() => this.paint()); + this.observer.observe(canvas); + } + this.paint(); + }; + + paint = () => { + const canvas = this.canvasRef; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const dstW = Math.round(rect.width); + const dstH = Math.round(rect.height); + if (!dstW || !dstH) return; + + canvas.width = dstW; + canvas.height = dstH; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const img = this.img; + if (img && img.complete && img.naturalWidth && img.naturalHeight) { + drawContainedWithStretchedEdges( + ctx, + img, + img.naturalWidth, + img.naturalHeight, + dstW, + dstH, + ); + return; + } + + const { thumbHash } = this.props; + if (thumbHash) { + const decoded = decodeThumbHash(thumbHash); + if (decoded) { + const { w, h, rgba } = decoded; + const temp = document.createElement('canvas'); + temp.width = w; + temp.height = h; + temp.getContext('2d').putImageData( + new ImageData(new Uint8ClampedArray(rgba), w, h), + 0, + 0, + ); + drawContainedWithStretchedEdges(ctx, temp, w, h, dstW, dstH); + } + } + }; + + render() { + return ( + + ); + } +} export class ArticleTile extends Tile { hasPhoto() { @@ -81,10 +279,7 @@ export class ArticleTile extends Tile { } render() { - if (!this.hasPhoto()) { - return super.render(); - } - + const hasPhoto = this.hasPhoto(); const { article, label, sx, disabled } = this.props; const sku = articleSku(article); @@ -104,15 +299,25 @@ export class ArticleTile extends Tile { }} > - + {hasPhoto ? ( + + ) : ( + + {this.renderIcon()} + + )} {this.renderExtra()} {article?.brand?.name ? ( @@ -136,7 +343,13 @@ export class ArticleTile extends Tile { typographyProps={{ variant: 'caption', noWrap: true, - sx: { color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.1 }, + sx: { + color: '#b3e5fc', + opacity: 1, + fontWeight: 500, + lineHeight: 1.1, + textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)', + }, }} sx={{ mb: 0.25 }} /> @@ -146,16 +359,40 @@ export class ArticleTile extends Tile { variant="body2" fontWeight={600} noWrap - sx={{ color: '#fff', lineHeight: 1.2 }} + sx={{ + color: '#111', + lineHeight: 1.2, + textShadow: '0 0 3px rgba(255, 255, 255, 1), 0 0 6px rgba(255, 255, 255, 0.9), 0 1px 2px rgba(255, 255, 255, 0.9)', + }} > {label} ) : null} {sku ? ( - + {sku} ) : null} + {article.net != null && ( + + {formatPrice(article.net)} + + )} ); diff --git a/src/devtools/AutomatInspector.jsx b/src/devtools/AutomatInspector.jsx index eefe56e..c89f07b 100644 --- a/src/devtools/AutomatInspector.jsx +++ b/src/devtools/AutomatInspector.jsx @@ -1,7 +1,6 @@ import { PureComponent, createRef } from 'react'; import Badge from '@mui/material/Badge'; import Box from '@mui/material/Box'; -import ButtonBase from '@mui/material/ButtonBase'; import Chip from '@mui/material/Chip'; import Fab from '@mui/material/Fab'; import IconButton from '@mui/material/IconButton'; @@ -10,16 +9,27 @@ import TextField from '@mui/material/TextField'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import BugReportIcon from '@mui/icons-material/BugReport'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import ClearIcon from '@mui/icons-material/Clear'; import CloseIcon from '@mui/icons-material/Close'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import RefreshIcon from '@mui/icons-material/Refresh'; import UnfoldLessIcon from '@mui/icons-material/UnfoldLess'; import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; import { enqueueSnackbar } from 'notistack'; import { notifyError } from '../utils/notify'; +import AutomatEntry from './inspector/AutomatEntry'; +import ActionLogPanel from './inspector/ActionLogPanel'; +import { + actionLog, + callListeners, + formatTime, + getRegistry, + installActionLogging, + logListeners, + notifyLog, + watchRegistry, +} from './inspector/actionLog'; +import { formatState } from './inspector/serialize'; + +export { formatState }; /** * Dev-only overlay that lists every Automat registered in the @@ -32,295 +42,6 @@ import { notifyError } from '../utils/notify'; /** Registry polling interval in ms; lazy automats (`delivery-`) appear over time. */ const REGISTRY_POLL_MS = 2000; -/** Serialization limits so huge states stay renderable. */ -const MAX_DEPTH = 6; -const MAX_STRING = 1500; -const MAX_ITEMS = 50; - -/** - * Live registry view. Named automats register themselves in the constructor, - * so the map may be absent until the first one is created. - * - * @returns {Map} Registered automats. - */ -function getRegistry() { - const registry = window.__AUTOMATS__; - return registry instanceof Map ? registry : new Map(); -} - -/** - * Quotes a string and truncates oversized values. - * - * @param {string} text Value to quote. - * @returns {string} Quoted, possibly truncated string. - */ -function quote(text) { - if (text.length <= MAX_STRING) return JSON.stringify(text); - const rest = text.length - MAX_STRING; - return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`; -} - -/** - * Renders `entries` as an indented block. - * - * @param {string} open Opening bracket. - * @param {string} close Closing bracket. - * @param {Array} entries Already serialized entries. - * @param {string} pad Indentation of the enclosing level. - * @returns {string} Indented block. - */ -function block(open, close, entries, pad) { - if (entries.length === 0) return `${open}${close}`; - const body = entries.map((entry) => `${pad} ${entry}`).join(',\n'); - return `${open}\n${body}\n${pad}${close}`; -} - -/** - * Serializes an arbitrary state value for display. Handles functions, blobs, - * DOM nodes, maps, sets and circular references, which plain `JSON.stringify` - * would drop or throw on. - * - * @param {*} value Value to serialize. - * @param {number} depth Current nesting depth. - * @param {WeakSet} ancestors Objects on the current path, for cycle detection. - * @param {string} pad Indentation of the current level. - * @returns {string} Human-readable representation. - */ -function serialize(value, depth, ancestors, pad) { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - - const type = typeof value; - if (type === 'string') return quote(value); - if (type === 'number' || type === 'boolean') return String(value); - if (type === 'bigint') return `${value}n`; - if (type === 'function') return `[Function ${value.name || 'anonym'}]`; - if (type === 'symbol') return `[Symbol ${value.description ?? ''}]`; - if (type !== 'object') return String(value); - - if (value instanceof Date) return `[Date ${value.toISOString()}]`; - if (value instanceof Blob) return `[Blob ${value.size}B ${value.type || 'ohne Typ'}]`; - if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength}B]`; - if (value instanceof Promise) return '[Promise]'; - if (typeof Node !== 'undefined' && value instanceof Node) return `[Node ${value.nodeName}]`; - - if (ancestors.has(value)) return '[Circular]'; - if (depth >= MAX_DEPTH) return Array.isArray(value) ? '[Array …]' : '[Object …]'; - - ancestors.add(value); - try { - if (value instanceof Map) { - const shown = Array.from(value.entries()).slice(0, MAX_ITEMS); - const entries = shown.map( - ([k, v]) => - `${serialize(k, depth + 1, ancestors, `${pad} `)}: ${serialize(v, depth + 1, ancestors, `${pad} `)}`, - ); - if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`); - return block(`Map(${value.size}) {`, '}', entries, pad); - } - - if (value instanceof Set) { - const shown = Array.from(value.values()).slice(0, MAX_ITEMS); - const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `)); - if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`); - return block(`Set(${value.size}) {`, '}', entries, pad); - } - - if (Array.isArray(value)) { - const shown = value.slice(0, MAX_ITEMS); - const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `)); - if (value.length > shown.length) entries.push(`${JSON.stringify('…')}: +${value.length - shown.length} weitere`); - return block('[', ']', entries, pad); - } - - const keys = Object.keys(value); - const shown = keys.slice(0, MAX_ITEMS); - const entries = shown.map( - (key) => `${JSON.stringify(key)}: ${serialize(value[key], depth + 1, ancestors, `${pad} `)}`, - ); - if (keys.length > shown.length) entries.push(`${JSON.stringify('…')}: +${keys.length - shown.length} weitere Schlüssel`); - - const label = value.constructor && value.constructor !== Object ? `${value.constructor.name} ` : ''; - return block(`${label}{`, '}', entries, pad); - } finally { - ancestors.delete(value); - } -} - -/** - * Formats an automat state for the overlay. Serialization failures are surfaced - * instead of being swallowed. - * - * @param {*} value Automat state. - * @returns {string} Display text. - */ -export function formatState(value) { - try { - return serialize(value, 0, new WeakSet(), ''); - } catch (err) { - return `[Serialisierung fehlgeschlagen: ${err?.message ?? err}]`; - } -} - -/** Maximum number of retained action log entries (ring buffer). */ -const MAX_LOG = 300; - -/** Marks action functions that already carry the logging proxy. */ -const LOGGED = Symbol('automatInspectorLogged'); - -/** Compact single-line rendering limit for args/results in the log. */ -const LOG_VALUE_LIMIT = 240; - -/** - * Ring buffer of captured action calls, oldest first. Kept at module scope so - * entries survive component re-renders (and are shared by every inspector). - * - * @type {Array<{id: number, time: Date, automat: string, action: string, args: string, status: string, duration: number|null, result: string, error: string|null}>} - */ -const actionLog = []; - -/** Monotonic id source for log entries. */ -let logSeq = 0; - -/** @type {Set<() => void>} Listeners notified when the log buffer changes. */ -const logListeners = new Set(); - -/** Notifies every log listener, swallowing individual errors. */ -function notifyLog() { - for (const listener of logListeners) { - try { - listener(); - } catch { - /* listeners are dev-only; never let one break logging */ - } - } -} - -/** - * Appends an entry to the ring buffer and notifies listeners. - * - * @param {object} entry Log entry. - */ -function pushLog(entry) { - actionLog.push(entry); - if (actionLog.length > MAX_LOG) actionLog.splice(0, actionLog.length - MAX_LOG); - notifyLog(); -} - -/** - * Renders a value as a compact single-line string for the log. - * - * @param {*} value Value to summarize. - * @param {number} [limit] Maximum length before truncation. - * @returns {string} Compact representation. - */ -function compact(value, limit = LOG_VALUE_LIMIT) { - const text = formatState(value).replace(/\s+/g, ' ').trim(); - return text.length > limit ? `${text.slice(0, limit)} …` : text; -} - -/** - * Formats a timestamp as `HH:MM:SS.mmm`. - * - * @param {Date} date Timestamp. - * @returns {string} Formatted time. - */ -function formatTime(date) { - const pad = (n, len = 2) => String(n).padStart(len, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`; -} - -/** - * Wraps every action of an automat with a logging proxy that records call - * arguments, duration, return value and errors. Idempotent: already wrapped - * actions are left untouched, and the original function is always invoked with - * the caller's `this` and arguments. - * - * @param {import('react-automat').Automat} automat Automat instance. - */ -function installActionLogging(automat) { - const actions = automat.actions; - if (!actions) return; - for (const key of Object.keys(actions)) { - const original = actions[key]; - if (typeof original !== 'function' || original[LOGGED]) continue; - - const wrapped = function loggedAction(...args) { - const started = performance.now(); - const entry = { - id: ++logSeq, - time: new Date(), - automat: automat.name ?? '(anonym)', - action: key, - args: compact(args), - status: 'running', - duration: null, - result: '', - error: null, - }; - pushLog(entry); - - const settle = (status, payload, err) => { - entry.status = status; - entry.duration = performance.now() - started; - if (status === 'error') entry.error = err?.message ?? String(err); - else entry.result = compact(payload); - notifyLog(); - }; - - let result; - try { - result = original.apply(this, args); - } catch (err) { - settle('error', undefined, err); - throw err; - } - - if (result && typeof result.then === 'function') { - return result.then( - (value) => { - settle('ok', value); - return value; - }, - (err) => { - settle('error', undefined, err); - throw err; - }, - ); - } - - settle('ok', result); - return result; - }; - - try { - Object.defineProperty(wrapped, 'name', { value: key }); - } catch { - /* name is cosmetic */ - } - wrapped[LOGGED] = true; - actions[key] = wrapped; - } -} - -/** Marks the registry Map once its `set` has been wrapped. */ -const WATCHED = Symbol('automatInspectorWatched'); - -/** - * Wraps the live registry's `set` so automats created after mount get their - * actions instrumented immediately instead of on the next poll tick. - */ -function watchRegistry() { - const registry = window.__AUTOMATS__; - if (!(registry instanceof Map) || registry[WATCHED]) return; - const originalSet = registry.set.bind(registry); - registry.set = (name, automat) => { - const result = originalSet(name, automat); - installActionLogging(automat); - return result; - }; - registry[WATCHED] = true; -} /** * Floating dev button plus a docked side panel listing all registered Automats @@ -343,7 +64,10 @@ class AutomatInspector extends PureComponent { revision: 0, logOpen: false, log: [], + flash: {}, }; + this.handleActionCall = this.handleActionCall.bind(this); + this.handleRunAction = this.handleRunAction.bind(this); /** @type {Map void>} Unsubscribe functions per automat name. */ this.unsubs = new Map(); @@ -372,13 +96,42 @@ class AutomatInspector extends PureComponent { this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS); window.addEventListener('keydown', this.handleKeyDown); logListeners.add(this.scheduleLogUpdate); + callListeners.add(this.handleActionCall); this.scheduleLogUpdate(); } + /** + * Bumps the flash counter of an action so its chip replays the flash animation. + * + * @param {string} automat Automat name. + * @param {string} action Action name. + */ + handleActionCall(automat, action) { + if (!this.state.open) return; + const id = `${automat}.${action}`; + this.setState((prev) => ({ flash: { ...prev.flash, [id]: (prev.flash[id] ?? 0) + 1 } })); + } + + /** + * Triggers a parameterless action manually. + * + * @param {string} name Automat name. + * @param {string} action Action name. + * @returns {Promise} + */ + async handleRunAction(name, action) { + try { + await getRegistry().get(name)?.actions?.[action]?.(); + } catch (err) { + notifyError(err); + } + } + componentWillUnmount() { window.clearInterval(this.registryTimer); window.removeEventListener('keydown', this.handleKeyDown); logListeners.delete(this.scheduleLogUpdate); + callListeners.delete(this.handleActionCall); this.unsubscribeAll(); if (this.frame) cancelAnimationFrame(this.frame); if (this.logFrame) cancelAnimationFrame(this.logFrame); @@ -561,221 +314,10 @@ class AutomatInspector extends PureComponent { * @param {boolean} isExpanded Whether the details are visible. * @returns {React.ReactElement} Entry element. */ - renderEntry(automat, isExpanded) { - const name = automat.name; - const actionNames = Object.keys(automat.actions ?? {}); - - return ( - - this.handleToggleEntry(name)} - sx={{ - width: '100%', - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-start', - gap: 1, - px: 1, - py: 0.75, - textAlign: 'left', - }} - > - {isExpanded ? : } - - {name} - - - - {automat.isDirty ? : null} - - - {isExpanded ? ( - - - {automat.url ? : null} - {automat.persist ? : null} - {automat.error ? ( - - ) : null} - - - - - Aktionen ({actionNames.length}) - - - this.handleCopy(name)}> - - - - - - {actionNames.length === 0 ? ( - - keine - - ) : ( - actionNames.map((action) => ) - )} - - - - State - - - {formatState(automat.state)} - - - ) : null} - - ); - } - - /** - * Renders one captured action call as a single log line. - * - * @param {object} entry Log entry. - * @returns {React.ReactElement} Log line. - */ - renderLogLine(entry) { - const color = - entry.status === 'error' ? 'error.main' : entry.status === 'running' ? 'text.secondary' : 'success.main'; - return ( - - - {formatTime(entry.time)}{' '} - - - {entry.automat}.{entry.action} - - - ({entry.args}) - - {entry.status === 'error' ? ( - - {' '} - ✗ {entry.error} - - ) : entry.status === 'ok' ? ( - - {' '} - → {entry.result} - - ) : null}{' '} - - {entry.status === 'running' - ? '…' - : `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`} - - - ); - } - - /** Bottom-docked, collapsible log of every captured automat action call. */ - renderLog() { - const { logOpen, log } = this.state; - const errorCount = log.filter((entry) => entry.status === 'error').length; - - return ( - - - - {logOpen ? : } - - Action-Log - - - {errorCount > 0 ? : null} - - - - - - - - - - - - - - - - - - {logOpen ? ( - - {log.length === 0 ? ( - - Noch keine Action-Aufrufe protokolliert. - - ) : ( - log.map((entry) => this.renderLogLine(entry)) - )} - - ) : null} - - ); - } /** Docked side panel with the automat list, filter and action log. */ renderPanel() { - const { names, expanded, query } = this.state; + const { names, expanded, query, flash, logOpen, log } = this.state; const registry = getRegistry(); const needle = query.trim().toLowerCase(); const entries = names @@ -848,12 +390,29 @@ class AutomatInspector extends PureComponent { ) : ( - {entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))} + {entries.map((automat) => ( + + ))} )} - {this.renderLog()} + ); } @@ -892,3 +451,4 @@ class AutomatInspector extends PureComponent { } export default AutomatInspector; + diff --git a/src/devtools/inspector/ActionLogPanel.jsx b/src/devtools/inspector/ActionLogPanel.jsx new file mode 100644 index 0000000..cddae9c --- /dev/null +++ b/src/devtools/inspector/ActionLogPanel.jsx @@ -0,0 +1,129 @@ +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import ClearIcon from '@mui/icons-material/Clear'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { formatTime } from './actionLog'; + +/** + * Renders one captured action call as a single log line. + * + * @param {{entry: object}} props Component props. + * @returns {React.ReactElement} Log line. + */ +function LogLine({ entry }) { + const color = + entry.status === 'error' ? 'error.main' : entry.status === 'running' ? 'text.secondary' : 'success.main'; + return ( + + + {formatTime(entry.time)}{' '} + + + {entry.automat}.{entry.action} + + + ({entry.args}) + + {entry.status === 'error' ? ( + + {' '} + ✗ {entry.error} + + ) : entry.status === 'ok' ? ( + + {' '} + → {entry.result} + + ) : null}{' '} + + {entry.status === 'running' + ? '…' + : `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`} + + + ); +} + +/** + * Bottom-docked, collapsible log of every captured automat action call. + * + * @param {object} props Component props. + * @param {boolean} props.logOpen Whether the log body is visible. + * @param {Array} props.log Log entries. + * @param {import('react').RefObject} props.logRef Ref of the scroll container. + * @param {() => void} props.onToggle Toggles the log. + * @param {() => void} props.onCopy Copies the log. + * @param {() => void} props.onClear Clears the log. + * @returns {React.ReactElement} Log panel. + */ +export default function ActionLogPanel({ logOpen, log, logRef, onToggle, onCopy, onClear }) { + const errorCount = log.filter((entry) => entry.status === 'error').length; + + return ( + + + + {logOpen ? : } + + Action-Log + + + {errorCount > 0 ? : null} + + + + + + + + + + + + + + + + + + {logOpen ? ( + + {log.length === 0 ? ( + + Noch keine Action-Aufrufe protokolliert. + + ) : ( + log.map((entry) => ) + )} + + ) : null} + + ); +} diff --git a/src/devtools/inspector/AutomatEntry.jsx b/src/devtools/inspector/AutomatEntry.jsx new file mode 100644 index 0000000..dbf6f6a --- /dev/null +++ b/src/devtools/inspector/AutomatEntry.jsx @@ -0,0 +1,154 @@ +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import Chip from '@mui/material/Chip'; +import IconButton from '@mui/material/IconButton'; +import Stack from '@mui/material/Stack'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { ARITY } from './actionLog'; +import { formatState } from './serialize'; + +/** + * One automat entry: header chips, flashing/clickable actions and the state dump. + * + * @param {object} props Component props. + * @param {import('react-automat').Automat} props.automat Automat instance. + * @param {boolean} props.isExpanded Whether the details are visible. + * @param {Object} props.flash Per-action call counters driving the flash animation. + * @param {(name: string) => void} props.onToggle Toggles the entry. + * @param {(name: string) => void} props.onCopy Copies the state. + * @param {(name: string, action: string) => void} props.onRunAction Runs a parameterless action. + * @returns {React.ReactElement} Entry element. + */ +export default function AutomatEntry({ automat, isExpanded, flash, onToggle, onCopy, onRunAction }) { + const name = automat.name; + const actionNames = Object.keys(automat.actions ?? {}); + + return ( + + onToggle(name)} + sx={{ + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + gap: 1, + px: 1, + py: 0.75, + textAlign: 'left', + }} + > + {isExpanded ? : } + + {name} + + + + {automat.isDirty ? : null} + + + {isExpanded ? ( + + + {automat.url ? : null} + {automat.persist ? : null} + {automat.error ? ( + + ) : null} + + + + + Aktionen ({actionNames.length}) + + + onCopy(name)}> + + + + + + {actionNames.length === 0 ? ( + + keine + + ) : ( + actionNames.map((action) => { + const fn = automat.actions[action]; + const runnable = fn?.[ARITY] === 0; + const count = flash[`${name}.${action}`] ?? 0; + return ( + onRunAction(name, action) : undefined} + sx={ + count > 0 + ? { + '@keyframes automatFlash': { + '0%': { backgroundColor: 'warning.main', boxShadow: '0 0 8px 2px orange' }, + '100%': { boxShadow: '0 0 0 0 transparent' }, + }, + animation: 'automatFlash 0.7s ease-out', + } + : undefined + } + /> + ); + }) + )} + + + + State + + + {formatState(automat.state)} + + + ) : null} + + ); +} diff --git a/src/devtools/inspector/actionLog.js b/src/devtools/inspector/actionLog.js new file mode 100644 index 0000000..3eb9254 --- /dev/null +++ b/src/devtools/inspector/actionLog.js @@ -0,0 +1,187 @@ +import { formatState } from './serialize'; + +/** + * Live registry view. Named automats register themselves in the constructor, + * so the map may be absent until the first one is created. + * + * @returns {Map} Registered automats. + */ +export function getRegistry() { + const registry = window.__AUTOMATS__; + return registry instanceof Map ? registry : new Map(); +} + + +/** Maximum number of retained action log entries (ring buffer). */ +const MAX_LOG = 300; + +/** Marks action functions that already carry the logging proxy. */ +const LOGGED = Symbol('automatInspectorLogged'); + +/** Compact single-line rendering limit for args/results in the log. */ +const LOG_VALUE_LIMIT = 240; + +/** + * Ring buffer of captured action calls, oldest first. Kept at module scope so + * entries survive component re-renders (and are shared by every inspector). + * + * @type {Array<{id: number, time: Date, automat: string, action: string, args: string, status: string, duration: number|null, result: string, error: string|null}>} + */ +export const actionLog = []; + +/** Monotonic id source for log entries. */ +let logSeq = 0; + +/** @type {Set<() => void>} Listeners notified when the log buffer changes. */ +export const logListeners = new Set(); + +/** Marks the declared parameter count on a wrapped action. */ +export const ARITY = Symbol('automatInspectorArity'); + +/** @type {Set<(automat: string, action: string) => void>} Listeners notified on every action call. */ +export const callListeners = new Set(); + +/** Notifies every log listener, swallowing individual errors. */ +export function notifyLog() { + for (const listener of logListeners) { + try { + listener(); + } catch { + /* listeners are dev-only; never let one break logging */ + } + } +} + +/** + * Appends an entry to the ring buffer and notifies listeners. + * + * @param {object} entry Log entry. + */ +function pushLog(entry) { + actionLog.push(entry); + if (actionLog.length > MAX_LOG) actionLog.splice(0, actionLog.length - MAX_LOG); + notifyLog(); +} + +/** + * Renders a value as a compact single-line string for the log. + * + * @param {*} value Value to summarize. + * @param {number} [limit] Maximum length before truncation. + * @returns {string} Compact representation. + */ +function compact(value, limit = LOG_VALUE_LIMIT) { + const text = formatState(value).replace(/\s+/g, ' ').trim(); + return text.length > limit ? `${text.slice(0, limit)} …` : text; +} + +/** + * Formats a timestamp as `HH:MM:SS.mmm`. + * + * @param {Date} date Timestamp. + * @returns {string} Formatted time. + */ +export function formatTime(date) { + const pad = (n, len = 2) => String(n).padStart(len, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`; +} + +/** + * Wraps every action of an automat with a logging proxy that records call + * arguments, duration, return value and errors. Idempotent: already wrapped + * actions are left untouched, and the original function is always invoked with + * the caller's `this` and arguments. + * + * @param {import('react-automat').Automat} automat Automat instance. + */ +export function installActionLogging(automat) { + const actions = automat.actions; + if (!actions) return; + for (const key of Object.keys(actions)) { + const original = actions[key]; + if (typeof original !== 'function' || original[LOGGED]) continue; + + const wrapped = function loggedAction(...args) { + const started = performance.now(); + const entry = { + id: ++logSeq, + time: new Date(), + automat: automat.name ?? '(anonym)', + action: key, + args: compact(args), + status: 'running', + duration: null, + result: '', + error: null, + }; + pushLog(entry); + for (const listener of callListeners) { + try { + listener(entry.automat, key); + } catch { + /* dev-only */ + } + } + + const settle = (status, payload, err) => { + entry.status = status; + entry.duration = performance.now() - started; + if (status === 'error') entry.error = err?.message ?? String(err); + else entry.result = compact(payload); + notifyLog(); + }; + + let result; + try { + result = original.apply(this, args); + } catch (err) { + settle('error', undefined, err); + throw err; + } + + if (result && typeof result.then === 'function') { + return result.then( + (value) => { + settle('ok', value); + return value; + }, + (err) => { + settle('error', undefined, err); + throw err; + }, + ); + } + + settle('ok', result); + return result; + }; + + try { + Object.defineProperty(wrapped, 'name', { value: key }); + } catch { + /* name is cosmetic */ + } + wrapped[LOGGED] = true; + wrapped[ARITY] = original.length; + actions[key] = wrapped; + } +} + +/** Marks the registry Map once its `set` has been wrapped. */ +const WATCHED = Symbol('automatInspectorWatched'); + +/** + * Wraps the live registry's `set` so automats created after mount get their + * actions instrumented immediately instead of on the next poll tick. + */ +export function watchRegistry() { + const registry = window.__AUTOMATS__; + if (!(registry instanceof Map) || registry[WATCHED]) return; + const originalSet = registry.set.bind(registry); + registry.set = (name, automat) => { + const result = originalSet(name, automat); + installActionLogging(automat); + return result; + }; + registry[WATCHED] = true; +} diff --git a/src/devtools/inspector/serialize.js b/src/devtools/inspector/serialize.js new file mode 100644 index 0000000..9019634 --- /dev/null +++ b/src/devtools/inspector/serialize.js @@ -0,0 +1,118 @@ +/** Serialization limits so huge states stay renderable. */ +const MAX_DEPTH = 6; +const MAX_STRING = 1500; +const MAX_ITEMS = 50; + +/** + * Quotes a string and truncates oversized values. + * + * @param {string} text Value to quote. + * @returns {string} Quoted, possibly truncated string. + */ +function quote(text) { + if (text.length <= MAX_STRING) return JSON.stringify(text); + const rest = text.length - MAX_STRING; + return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`; +} + +/** + * Renders `entries` as an indented block. + * + * @param {string} open Opening bracket. + * @param {string} close Closing bracket. + * @param {Array} entries Already serialized entries. + * @param {string} pad Indentation of the enclosing level. + * @returns {string} Indented block. + */ +function block(open, close, entries, pad) { + if (entries.length === 0) return `${open}${close}`; + const body = entries.map((entry) => `${pad} ${entry}`).join(',\n'); + return `${open}\n${body}\n${pad}${close}`; +} + +/** + * Serializes an arbitrary state value for display. Handles functions, blobs, + * DOM nodes, maps, sets and circular references, which plain `JSON.stringify` + * would drop or throw on. + * + * @param {*} value Value to serialize. + * @param {number} depth Current nesting depth. + * @param {WeakSet} ancestors Objects on the current path, for cycle detection. + * @param {string} pad Indentation of the current level. + * @returns {string} Human-readable representation. + */ +function serialize(value, depth, ancestors, pad) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + const type = typeof value; + if (type === 'string') return quote(value); + if (type === 'number' || type === 'boolean') return String(value); + if (type === 'bigint') return `${value}n`; + if (type === 'function') return `[Function ${value.name || 'anonym'}]`; + if (type === 'symbol') return `[Symbol ${value.description ?? ''}]`; + if (type !== 'object') return String(value); + + if (value instanceof Date) return `[Date ${value.toISOString()}]`; + if (value instanceof Blob) return `[Blob ${value.size}B ${value.type || 'ohne Typ'}]`; + if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength}B]`; + if (value instanceof Promise) return '[Promise]'; + if (typeof Node !== 'undefined' && value instanceof Node) return `[Node ${value.nodeName}]`; + + if (ancestors.has(value)) return '[Circular]'; + if (depth >= MAX_DEPTH) return Array.isArray(value) ? '[Array …]' : '[Object …]'; + + ancestors.add(value); + try { + if (value instanceof Map) { + const shown = Array.from(value.entries()).slice(0, MAX_ITEMS); + const entries = shown.map( + ([k, v]) => + `${serialize(k, depth + 1, ancestors, `${pad} `)}: ${serialize(v, depth + 1, ancestors, `${pad} `)}`, + ); + if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`); + return block(`Map(${value.size}) {`, '}', entries, pad); + } + + if (value instanceof Set) { + const shown = Array.from(value.values()).slice(0, MAX_ITEMS); + const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `)); + if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`); + return block(`Set(${value.size}) {`, '}', entries, pad); + } + + if (Array.isArray(value)) { + const shown = value.slice(0, MAX_ITEMS); + const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `)); + if (value.length > shown.length) entries.push(`${JSON.stringify('…')}: +${value.length - shown.length} weitere`); + return block('[', ']', entries, pad); + } + + const keys = Object.keys(value); + const shown = keys.slice(0, MAX_ITEMS); + const entries = shown.map( + (key) => `${JSON.stringify(key)}: ${serialize(value[key], depth + 1, ancestors, `${pad} `)}`, + ); + if (keys.length > shown.length) entries.push(`${JSON.stringify('…')}: +${keys.length - shown.length} weitere Schlüssel`); + + const label = value.constructor && value.constructor !== Object ? `${value.constructor.name} ` : ''; + return block(`${label}{`, '}', entries, pad); + } finally { + ancestors.delete(value); + } +} + +/** + * Formats an automat state for the overlay. Serialization failures are surfaced + * instead of being swallowed. + * + * @param {*} value Automat state. + * @returns {string} Display text. + */ +export function formatState(value) { + try { + return serialize(value, 0, new WeakSet(), ''); + } catch (err) { + return `[Serialisierung fehlgeschlagen: ${err?.message ?? err}]`; + } +} diff --git a/src/utils/articleDetail.js b/src/utils/articleDetail.js index e579a79..b3a88e2 100644 --- a/src/utils/articleDetail.js +++ b/src/utils/articleDetail.js @@ -69,7 +69,9 @@ export function primaryPrice(article) { export function priceBrutto(price) { if (price?.net == null || price?.taxRate == null) return null; - return price.net * (1 + price.taxRate / 100); + const gross = price.net * (1 + price.taxRate / 100); + // Round to cents; epsilon guards float noise (e.g. 9.994999999 vs 9.995). + return Math.round((gross + Number.EPSILON) * 100) / 100; } /** diff --git a/src/utils/cropLogo.js b/src/utils/cropLogo.js index 9ee2ac6..0f35f62 100644 --- a/src/utils/cropLogo.js +++ b/src/utils/cropLogo.js @@ -240,9 +240,14 @@ export function autoCropWhiteAndTransparent(img) { return canvas; } - // Crop tightly to bounding box - const cropW = maxX - minX + 1; - const cropH = maxY - minY + 1; + // Restore up to 5 pixels border around the detected content + const PADDING = 5; + const cropX = Math.max(0, minX - PADDING); + const cropY = Math.max(0, minY - PADDING); + const cropMaxX = Math.min(width - 1, maxX + PADDING); + const cropMaxY = Math.min(height - 1, maxY + PADDING); + const cropW = cropMaxX - cropX + 1; + const cropH = cropMaxY - cropY + 1; const croppedCanvas = document.createElement('canvas'); croppedCanvas.width = cropW; @@ -253,7 +258,7 @@ export function autoCropWhiteAndTransparent(img) { return canvas; } - croppedCtx.drawImage(canvas, minX, minY, cropW, cropH, 0, 0, cropW, cropH); + croppedCtx.drawImage(canvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH); return croppedCanvas; }