From ec5e771c2bc73f68632a744a285b912c7917c48f Mon Sep 17 00:00:00 2001 From: mengnanxyyyy <308822704+mengnanxyyyy@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:50:03 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E8=81=8A=E5=A4=A9=E4=B8=AD?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20mermaid=20=E5=9B=BE=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BC=A9=E6=94=BE=E4=B8=8E=E5=B9=B3=E7=A7=BB?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天消息中的 mermaid 代码块默认渲染为可交互预览,点击打开缩放/平移查看器: - 滚轮缩放、拖拽平移、适应窗口、重置为 100% - 按比例重渲染,保持 SVG 矢量清晰 - 新增文本选择模式,可复制图表文字 - 补充 en / zh-CN 界面文案 --- app/globals.css | 78 +++++- components/MarkdownBody.tsx | 2 +- components/MermaidBlock.tsx | 112 +------- components/ZoomPanViewer.tsx | 501 +++++++++++++++++++++++++++++++++++ lib/i18n/messages/en.ts | 4 + lib/i18n/messages/zh-CN.ts | 4 + 6 files changed, 590 insertions(+), 111 deletions(-) create mode 100644 components/ZoomPanViewer.tsx diff --git a/app/globals.css b/app/globals.css index 1c0e917bd..fc764facb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -493,6 +493,14 @@ pre, code { text-overflow: ellipsis; white-space: nowrap; } +.mermaid-zoom-hint { + min-width: 0; + overflow: hidden; + color: var(--text-dim); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} .mermaid-zoom-actions { display: flex; align-items: center; @@ -548,36 +556,88 @@ pre, code { outline: 2px solid var(--accent); outline-offset: -2px; } -.mermaid-zoom-value { +/* Select-text mode toggle: green when active. */ +.mermaid-zoom-select-button.is-active { + border-color: color-mix(in srgb, #22c55e 55%, var(--border)); + background: color-mix(in srgb, #22c55e 16%, transparent); + color: #22c55e; +} +.mermaid-zoom-select-button.is-active:hover { + background: color-mix(in srgb, #22c55e 26%, transparent); + color: #4ade80; +} +button.mermaid-zoom-value { + width: auto; min-width: 48px; height: 22px; + padding: 0 6px; + border: 0; + border-radius: 0; + background: transparent; color: var(--text-muted); font-family: var(--font-mono); font-size: 12px; line-height: 22px; text-align: center; + cursor: pointer; user-select: none; + touch-action: manipulation; +} +button.mermaid-zoom-value:hover { + background: var(--bg-hover); + color: var(--text); +} +button.mermaid-zoom-value:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } .mermaid-zoom-viewport { + position: relative; min-width: 0; min-height: 0; - overflow: auto; - padding: 24px; + overflow: hidden; background: var(--bg); - -webkit-overflow-scrolling: touch; + touch-action: none; + user-select: none; + -webkit-user-select: none; + cursor: grab; +} +/* Select mode: hand the pointer to the browser so SVG text can be selected. */ +.mermaid-zoom-viewport.is-selecting { + cursor: default; + user-select: text; + -webkit-user-select: text; +} +.mermaid-zoom-viewport.is-selecting .mermaid-zoom-canvas, +.mermaid-zoom-viewport.is-selecting .mermaid-zoom-canvas svg, +.mermaid-zoom-viewport.is-selecting .mermaid-zoom-canvas svg text { + user-select: text; + -webkit-user-select: text; +} +.mermaid-zoom-viewport.is-dragging { + cursor: grabbing; +} +.mermaid-zoom-world { + position: absolute; + /* left/top positioning (not transform) keeps native SVG text selection + working — composited transform layers can break it in Chromium. */ + top: 0; + left: 0; } .mermaid-zoom-canvas { + box-sizing: border-box; + width: 100%; + height: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 7px; background: var(--bg-panel); - margin: 0 auto; - transition: width 0.15s ease; } .mermaid-zoom-canvas svg { display: block; - width: 100% !important; + width: 100%; height: auto; + /* viewBox keeps the aspect ratio; re-render at scale keeps vectors crisp. */ max-width: none !important; } @@ -590,8 +650,8 @@ pre, code { font-size: 10px; } - .mermaid-zoom-viewport { - padding: 12px; + .mermaid-zoom-hint { + display: none; } } diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index cc6bd6822..d83279d36 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -25,7 +25,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile const isBlock = className?.includes("language-") || raw.includes("\n"); if (isBlock) { if (lang === "mermaid") { - return ; + return ; } return ; } diff --git a/components/MermaidBlock.tsx b/components/MermaidBlock.tsx index 0efd3db95..b3e204907 100644 --- a/components/MermaidBlock.tsx +++ b/components/MermaidBlock.tsx @@ -1,12 +1,13 @@ "use client"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { useTheme } from "@/hooks/useTheme"; import { useI18n } from "@/hooks/useI18n"; import { copyText } from "@/lib/clipboard"; +import { prepareSvgForZoomPan, ZoomPanViewer } from "@/components/ZoomPanViewer"; interface MermaidBlockProps { code: string; @@ -14,10 +15,6 @@ interface MermaidBlockProps { defaultPreview?: boolean; } -const ZOOM_STEP = 0.25; -const ZOOM_MIN = 0.5; -const ZOOM_MAX = 3; - type RenderState = | { key: string; status: "loading" } | { key: string; status: "error" } @@ -118,105 +115,18 @@ export function MermaidBlock({ code, isStreaming, defaultPreview = false }: Merm function MermaidZoomDialog({ svg, onClose }: { svg: string; onClose: () => void }) { const { t } = useI18n(); - const dialogRef = useRef(null); - const [zoom, setZoom] = useState(1); - - useEffect(() => { - const dialog = dialogRef.current; - if (!dialog) return; - - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; - dialog.showModal(); - - return () => { - document.body.style.overflow = previousOverflow; - if (dialog.open) dialog.close(); - }; - }, []); + const prepared = useMemo(() => prepareSvgForZoomPan(svg), [svg]); return ( - { - event.preventDefault(); - onClose(); - }} - onKeyDown={(event) => { - if (event.key !== "Escape") return; - event.preventDefault(); - onClose(); - }} + -
-
- {t("i18n.mermaidDiagram")} -
-
- - {Math.round(zoom * 100)}% - -
- - -
-
-
{ - if (event.target === event.currentTarget) onClose(); - }} - > -
-
-
-
+
+ ); } diff --git a/components/ZoomPanViewer.tsx b/components/ZoomPanViewer.tsx new file mode 100644 index 000000000..48c9c6471 --- /dev/null +++ b/components/ZoomPanViewer.tsx @@ -0,0 +1,501 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type ReactNode, + type WheelEvent as ReactWheelEvent, +} from "react"; +import { useI18n } from "@/hooks/useI18n"; + +const RELATIVE_ZOOM_STEP = 0.25; +const RELATIVE_ZOOM_MIN = 0.25; +const RELATIVE_ZOOM_MAX = 3; +const FIT_MARGIN = 0.9; +const DRAG_THRESHOLD_PX = 4; +/** Matches .mermaid-zoom-canvas padding(12)*2 + border(1)*2 */ +const CANVAS_CHROME = 26; + +export interface ZoomPanViewerProps { + children: ReactNode; + /** Logical content width in CSS pixels (e.g. SVG viewBox width). */ + contentWidth: number; + /** Logical content height in CSS pixels (e.g. SVG viewBox height). */ + contentHeight: number; + title?: string; + ariaLabel?: string; + onClose: () => void; +} + +interface Transform { + scale: number; + tx: number; + ty: number; +} + +function clamp(n: number, min: number, max: number): number { + return Math.min(max, Math.max(min, n)); +} + +function fitScaleFor(viewportW: number, viewportH: number, contentW: number, contentH: number): number { + if (contentW <= 0 || contentH <= 0 || viewportW <= 0 || viewportH <= 0) return 1; + return Math.min((viewportW * FIT_MARGIN) / contentW, (viewportH * FIT_MARGIN) / contentH); +} + +function centerTransform(scale: number, viewportW: number, viewportH: number, contentW: number, contentH: number): Transform { + return { + scale, + tx: (viewportW - contentW * scale) / 2, + ty: (viewportH - contentH * scale) / 2, + }; +} + +/** + * Fullscreen dialog with industry-standard zoom-to-cursor + drag-to-pan. + * Scale is absolute; the toolbar shows relative zoom where 100% = fit-to-viewport. + */ +export function ZoomPanViewer({ + children, + contentWidth, + contentHeight, + title, + ariaLabel, + onClose, +}: ZoomPanViewerProps) { + const { t } = useI18n(); + const dialogRef = useRef(null); + const viewportRef = useRef(null); + + // World box includes canvas padding + border so fit math matches painted size. + const worldWidth = contentWidth + CANVAS_CHROME; + const worldHeight = contentHeight + CANVAS_CHROME; + + const [fitScale, setFitScale] = useState(1); + const [transform, setTransform] = useState({ scale: 1, tx: 0, ty: 0 }); + const [dragging, setDragging] = useState(false); + // Select mode: toolbar toggle that hands the mouse to the browser so SVG + // text can be selected and copied. Default (off) = drag to pan. + const [selectMode, setSelectMode] = useState(false); + // Timestamp of the last finished pan drag; suppresses the click that the + // browser fires after a drag so it cannot close the dialog. + const dragEndedAtRef = useRef(0); + + const fitScaleRef = useRef(fitScale); + const transformRef = useRef(transform); + fitScaleRef.current = fitScale; + transformRef.current = transform; + + const dragRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originTx: number; + originTy: number; + moved: boolean; + capturing: boolean; + } | null>(null); + + const applyScaleAtPoint = useCallback( + (nextScale: number, pivotX: number, pivotY: number) => { + const { scale, tx, ty } = transformRef.current; + const fs = fitScaleRef.current; + const minS = fs * RELATIVE_ZOOM_MIN; + const maxS = fs * RELATIVE_ZOOM_MAX; + const s = clamp(nextScale, minS, maxS); + if (s === scale) return; + // Keep content point under the pivot fixed: c = (p - t) / s → t' = p - c * s' + setTransform({ + scale: s, + tx: pivotX - ((pivotX - tx) * s) / scale, + ty: pivotY - ((pivotY - ty) * s) / scale, + }); + }, + [], + ); + + const zoomByRelativeStep = useCallback( + (deltaSteps: number) => { + const viewport = viewportRef.current; + if (!viewport) return; + const rect = viewport.getBoundingClientRect(); + const pivotX = rect.width / 2; + const pivotY = rect.height / 2; + const fs = fitScaleRef.current; + const currentRelative = transformRef.current.scale / fs; + const nextRelative = currentRelative + deltaSteps * RELATIVE_ZOOM_STEP; + applyScaleAtPoint(nextRelative * fs, pivotX, pivotY); + }, + [applyScaleAtPoint], + ); + + const fitToViewport = useCallback(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const { clientWidth: vw, clientHeight: vh } = viewport; + const fs = fitScaleFor(vw, vh, worldWidth, worldHeight); + setFitScale(fs); + setTransform(centerTransform(fs, vw, vh, worldWidth, worldHeight)); + }, [worldWidth, worldHeight]); + + // Open dialog, lock body scroll, initial fit. + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + dialog.showModal(); + + // Measure after layout so clientWidth/Height are valid. + const frame = requestAnimationFrame(() => { + fitToViewport(); + }); + + return () => { + cancelAnimationFrame(frame); + document.body.style.overflow = previousOverflow; + if (dialog.open) dialog.close(); + }; + }, [fitToViewport]); + + // Recalculate fit baseline on viewport resize; preserve relative zoom & center. + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport || typeof ResizeObserver === "undefined") return; + + let ready = false; + const ro = new ResizeObserver(() => { + // Skip the first callback — initial fit is handled by the open effect. + if (!ready) { + ready = true; + return; + } + const { clientWidth: vw, clientHeight: vh } = viewport; + if (vw <= 0 || vh <= 0) return; + + const prevFs = fitScaleRef.current; + const prev = transformRef.current; + const relative = prev.scale / prevFs; + const nextFs = fitScaleFor(vw, vh, worldWidth, worldHeight); + const nextScale = clamp(relative * nextFs, nextFs * RELATIVE_ZOOM_MIN, nextFs * RELATIVE_ZOOM_MAX); + + // Keep the content point that was at viewport center still centered. + const cx = vw / 2; + const cy = vh / 2; + const contentX = (cx - prev.tx) / prev.scale; + const contentY = (cy - prev.ty) / prev.scale; + + setFitScale(nextFs); + setTransform({ + scale: nextScale, + tx: cx - contentX * nextScale, + ty: cy - contentY * nextScale, + }); + }); + + ro.observe(viewport); + return () => ro.disconnect(); + }, [worldWidth, worldHeight]); + + const onWheel = (event: ReactWheelEvent) => { + event.preventDefault(); + const viewport = viewportRef.current; + if (!viewport) return; + + const rect = viewport.getBoundingClientRect(); + const pivotX = event.clientX - rect.left; + const pivotY = event.clientY - rect.top; + + // Smooth multiplicative zoom from wheel delta (trackpad + mouse). + const factor = Math.exp(-event.deltaY * 0.0015); + applyScaleAtPoint(transformRef.current.scale * factor, pivotX, pivotY); + }; + + const onPointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0) return; + // In select mode, leave the gesture entirely to the browser so SVG text + // can be selected and copied (no capture, no tracking). + if (selectMode) return; + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originTx: transformRef.current.tx, + originTy: transformRef.current.ty, + moved: false, + capturing: false, + }; + }; + + const onPointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + + if (!drag.moved) { + if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; + drag.moved = true; + drag.capturing = true; + setDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + } + + setTransform((prev) => ({ + ...prev, + tx: drag.originTx + dx, + ty: drag.originTy + dy, + })); + }; + + const endPointer = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + if (drag.capturing && event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + const wasDrag = drag.moved; + dragRef.current = null; + setDragging(false); + + // The browser may still fire a click after a pan drag; swallow it so a + // drag can never close the dialog. + if (wasDrag) dragEndedAtRef.current = Date.now(); + }; + + const onClick = (event: React.MouseEvent) => { + if (Date.now() - dragEndedAtRef.current < 500) return; + // Click on empty viewport chrome closes, unless the user just selected + // text (a selection means this was a text-drag, not a plain click). + if (event.target !== event.currentTarget) return; + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) return; + onClose(); + }; + + const onDoubleClick = (event: React.MouseEvent) => { + // In select mode, leave double-click word selection to the browser. + if (selectMode) return; + event.preventDefault(); + const viewport = viewportRef.current; + if (!viewport) return; + const rect = viewport.getBoundingClientRect(); + const pivotX = event.clientX - rect.left; + const pivotY = event.clientY - rect.top; + const fs = fitScaleRef.current; + const relative = transformRef.current.scale / fs; + // Toggle fit (100%) ↔ 200%. + const targetRelative = Math.abs(relative - 1) < 0.05 ? 2 : 1; + if (targetRelative === 1) { + fitToViewport(); + } else { + applyScaleAtPoint(targetRelative * fs, pivotX, pivotY); + } + }; + + const relativePercent = Math.round((transform.scale / fitScale) * 100); + const minScale = fitScale * RELATIVE_ZOOM_MIN; + const maxScale = fitScale * RELATIVE_ZOOM_MAX; + + return ( + { + event.preventDefault(); + onClose(); + }} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + onClose(); + }} + > +
+
+ {title ? {title} : null} + {!selectMode && ( + {t("i18n.zoomPanHint")} + )} +
+ +
+ + + +
+ + +
+
+
+
+
{children}
+
+
+
+
+ ); +} + +/** + * Parse a Mermaid-rendered SVG string into logical size + cleaned markup. + * Prefer viewBox; fall back to width/height attributes. Strip max-width forcing. + */ +export function prepareSvgForZoomPan(svg: string): { + html: string; + width: number; + height: number; +} { + if (typeof DOMParser === "undefined") { + return { html: svg, width: 800, height: 600 }; + } + + const doc = new DOMParser().parseFromString(svg, "image/svg+xml"); + const el = doc.documentElement; + if (!el || el.tagName.toLowerCase() !== "svg") { + return { html: svg, width: 800, height: 600 }; + } + + let width = 0; + let height = 0; + + const viewBox = el.getAttribute("viewBox"); + if (viewBox) { + const parts = viewBox + .trim() + .split(/[\s,]+/) + .map(Number); + if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) { + width = parts[2]; + height = parts[3]; + } + } + + if (!(width > 0 && height > 0)) { + width = parseFloat(el.getAttribute("width") || ""); + height = parseFloat(el.getAttribute("height") || ""); + } + + if (!(width > 0 && height > 0)) { + width = 800; + height = 600; + } + + // Resolution-independent: drop the fixed pixel size and stretch constraints. + // The viewer re-renders the SVG at scale via CSS width/height, so the browser + // vector-draws it at the final resolution (crisp at any zoom level). + el.removeAttribute("width"); + el.removeAttribute("height"); + el.style.maxWidth = "none"; + // Ensure viewBox exists so height:auto keeps the aspect ratio. + if (!el.getAttribute("viewBox")) { + el.setAttribute("viewBox", `0 0 ${width} ${height}`); + } + + return { html: el.outerHTML, width, height }; +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961c..9d8099adf 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -324,6 +324,10 @@ export const enLocale: LocalePlugin = { "i18n.zoomOut": "Zoom out", "i18n.zoomIn": "Zoom in", "i18n.fitToWidth": "Fit to width", + "i18n.fitToViewport": "Fit to viewport", + "i18n.resetZoom": "Reset to 100% (fit)", + "i18n.zoomPanHint": "Drag to pan · Scroll to zoom", + "i18n.selectText": "Select text", "models.discoveryFetching": "Importing models…", "models.discoveryFetch": "Import models…", "models.discoveryFilterPlaceholder": "Filter {count} models…", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f8142..42054a98d 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -324,6 +324,10 @@ export const zhCNLocale: LocalePlugin = { "i18n.zoomOut": "缩小", "i18n.zoomIn": "放大", "i18n.fitToWidth": "适应宽度", + "i18n.fitToViewport": "适应窗口", + "i18n.resetZoom": "重置为 100%(适应窗口)", + "i18n.zoomPanHint": "拖动平移 · 滚轮缩放", + "i18n.selectText": "选择文本", "models.discoveryFetching": "正在导入模型…", "models.discoveryFetch": "导入模型…", "models.discoveryFilterPlaceholder": "筛选 {count} 个模型…", From 3f57a476266d4ae064050a465795a3b01e158df7 Mon Sep 17 00:00:00 2001 From: mengnanxyyyy <308822704+mengnanxyyyy@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:13:55 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20html=20=E4=BB=A3=E7=A0=81=E5=9D=97?= =?UTF-8?q?=E9=99=90=E9=AB=98=E5=B1=95=E5=BC=80=E4=B8=8E=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=20sandbox=20=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 消息内 html 代码块默认限高 300px,超长显示"展开全文/收起"(仅 html,其他代码块不受影响) - 代码块头部"预览 HTML"按钮,流式输出中禁用 - 右侧面板新增 HTML 预览视图:沙盒 iframe(sandbox allow-scripts/popups/modals + srcdoc)渲染,与宿主页面隔离 - 预览拥有独立 tabs:标题取文档 ,无标题显示"无标题",固定宽度 150px;tab 按 sessionId:entryId:blockIndex 去重,会话切换自动清理 - 右面板文件/预览视图切换:图标按钮(SVG 线条风格)并入 tab 行,激活态 accent 色,头部统一 36px 与主顶栏对齐 - 预览懒挂载:点击才创建 iframe,切换/关闭即销毁,同时最多 1 个 iframe 运行 - 修复 SyntaxHighlighter customStyle 中 background shorthand 与主题 backgroundColor 冲突的 React 警告(CodeBlock 与 FileViewer) --- app/globals.css | 61 ++++ components/AppShell.tsx | 148 ++++++--- components/ChatWindow.tsx | 7 +- components/FileViewer.tsx | 3 +- components/HtmlPreviewBlock.test.mjs | 59 ++++ components/HtmlPreviewBlock.tsx | 107 ++++++ components/HtmlPreviewPanel.tsx | 163 ++++++++++ components/MarkdownBody.tsx | 11 +- components/MermaidBlock.tsx | 10 +- components/MessageView.tsx | 27 +- components/MobilePwaLayout.test.mjs | 2 +- components/RightPanelViewSwitcher.test.mjs | 48 +++ components/RightPanelViewSwitcher.tsx | 49 +++ lib/i18n/messages/en.ts | 7 + lib/i18n/messages/zh-CN.ts | 7 + package-lock.json | 357 +++++++++++++++++---- 16 files changed, 952 insertions(+), 114 deletions(-) create mode 100644 components/HtmlPreviewBlock.test.mjs create mode 100644 components/HtmlPreviewBlock.tsx create mode 100644 components/HtmlPreviewPanel.tsx create mode 100644 components/RightPanelViewSwitcher.test.mjs create mode 100644 components/RightPanelViewSwitcher.tsx diff --git a/app/globals.css b/app/globals.css index fc764facb..e5eced2c9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -422,6 +422,67 @@ pre, code { color: var(--text); } +/* HTML code block: collapsed height + expand toggle */ +.markdown-code-collapse { + position: relative; +} +.markdown-code-collapse-body { + position: relative; +} +.markdown-code-fade { + position: absolute; + left: 1px; + right: 1px; + bottom: 0; + height: 64px; + border-radius: 0 0 7px 7px; + background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 88%, transparent)); + pointer-events: none; +} +.markdown-code-expand { + position: absolute; + left: 50%; + bottom: 8px; + transform: translateX(-50%); + border: 1px solid var(--border); + border-radius: 5px; + padding: 3px 12px; + background: var(--bg-panel); + color: var(--text-muted); + cursor: pointer; + font: inherit; + font-size: 11px; + line-height: 1.4; + box-shadow: 0 1px 3px color-mix(in srgb, #000 18%, transparent); +} +.markdown-code-expand:hover { + color: var(--text); + border-color: color-mix(in srgb, var(--border) 70%, var(--text-muted)); +} + +/* Right panel view switcher — icon buttons, active view uses the accent color */ +.right-panel-view-switch { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.right-panel-view-switch:hover { + color: var(--text); + background: var(--bg-hover); +} +.right-panel-view-switch.is-active { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + .mermaid-block { padding: 12px; overflow-x: auto; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index d4d827fa7..0b5d8dfd2 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -7,6 +7,9 @@ import { SessionSidebar } from "./SessionSidebar"; import { ChatWindow } from "./ChatWindow"; import { FileViewer } from "./FileViewer"; import { TabBar, type Tab } from "./TabBar"; +import { HtmlPreviewPanel, type HtmlPreviewTab } from "./HtmlPreviewPanel"; +import { RightPanelViewSwitcher } from "./RightPanelViewSwitcher"; +import type { HtmlPreviewRequest } from "./HtmlPreviewBlock"; import { ModelsConfig } from "./ModelsConfig"; import { SkillsConfig } from "./SkillsConfig"; import { PluginsConfig } from "./PluginsConfig"; @@ -246,6 +249,11 @@ export function AppShell() { const [fileTabs, setFileTabs] = useState<Tab[]>([]); const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null); + // Right panel — html preview tabs (separate view from file tabs) + const [htmlPreviewTabs, setHtmlPreviewTabs] = useState<HtmlPreviewTab[]>([]); + const [activeHtmlPreviewTabId, setActiveHtmlPreviewTabId] = useState<string | null>(null); + const [rightPanelView, setRightPanelView] = useState<"files" | "preview">("files"); + // Same @mention format as the chat input's @ autocomplete, so the agent's // read tool resolves it the same way (it strips the @ prefix). const handleAtMention = useCallback((relativePath: string, isDir: boolean) => { @@ -344,6 +352,9 @@ export function AppShell() { // now-empty right panel. setFileTabs([]); setActiveFileTabId(null); + setHtmlPreviewTabs([]); + setActiveHtmlPreviewTabId(null); + setRightPanelView("files"); setRightPanelOpen(false); router.replace("/", { scroll: false }); }, [router, selectedSession]); @@ -539,6 +550,41 @@ export function AppShell() { }); }, [fileTabs]); + // Open (or focus) an html preview tab from a chat code block. + const handleOpenHtmlPreview = useCallback((request: HtmlPreviewRequest) => { + const tabId = `preview:${request.key}`; + setHtmlPreviewTabs((prev) => { + const existing = prev.find((tab) => tab.id === tabId); + if (existing) { + if (existing.title === request.title && existing.html === request.html) return prev; + return prev.map((tab) => (tab.id === tabId ? { ...tab, title: request.title, html: request.html } : tab)); + } + return [...prev, { id: tabId, title: request.title, html: request.html }]; + }); + setActiveHtmlPreviewTabId(tabId); + setRightPanelView("preview"); + setRightPanelOpen(true); + // On mobile the file panel is full-screen; close the drawer so it shows. + if (isMobile) setSidebarOpen(false); + }, [isMobile]); + + const handleCloseHtmlPreviewTab = useCallback((tabId: string) => { + setHtmlPreviewTabs((prev) => { + const next = prev.filter((tab) => tab.id !== tabId); + if (next.length === 0) { + setActiveHtmlPreviewTabId(null); + setRightPanelView("files"); + setRightPanelOpen(false); + } + return next; + }); + setActiveHtmlPreviewTabId((cur) => { + if (cur !== tabId) return cur; + const remaining = htmlPreviewTabs.filter((tab) => tab.id !== tabId); + return remaining.length > 0 ? remaining[remaining.length - 1].id : null; + }); + }, [htmlPreviewTabs]); + const handleViewFullHistory = useCallback(() => { if (!selectedSession) return; window.open( @@ -1494,6 +1540,7 @@ export function AppShell() { onSessionStatsPanelOpen={openSessionStatsPanel} onContextUsageChange={handleContextUsageChange} onOpenFile={handleOpenLinkedFile} + onOpenHtmlPreview={handleOpenHtmlPreview} /> ) : initialCwdStatus === "validating" ? ( <div @@ -1554,7 +1601,7 @@ export function AppShell() { /> )} - {/* Right panel: file viewer — always mounted, width animated via CSS */} + {/* Right panel: file viewer + html preview — always mounted, width animated via CSS */} <div ref={rightPanelResizer.panelRef} id="file-panel" @@ -1567,49 +1614,68 @@ export function AppShell() { background: "var(--bg)", } as React.CSSProperties} > - {/* Right panel tab bar */} - <div style={{ - display: "flex", - alignItems: "center", - flexShrink: 0, - height: "calc(36px + env(safe-area-inset-top))", - paddingTop: "env(safe-area-inset-top)", - background: "var(--bg-panel)", - borderBottom: "1px solid var(--border)", - }}> - <div style={{ flex: 1, overflow: "hidden" }}> - <TabBar - tabs={fileTabs} - activeTabId={activeFileTabId ?? ""} - onSelectTab={setActiveFileTabId} - onCloseTab={handleCloseFileTab} - /> - </div> - - </div> + {rightPanelView === "files" ? ( + <> + {/* Right panel tab bar with view switcher — same height as the main top bar */} + <div style={{ + display: "flex", + alignItems: "center", + flexShrink: 0, + height: "calc(36px + env(safe-area-inset-top))", + paddingTop: "env(safe-area-inset-top)", + background: "var(--bg-panel)", + borderBottom: "1px solid var(--border)", + }}> + <div style={{ alignSelf: "center" }}> + <RightPanelViewSwitcher + view={rightPanelView} + onViewChange={setRightPanelView} + previewAvailable={htmlPreviewTabs.length > 0} + /> + </div> + <div style={{ flex: 1, overflow: "hidden" }}> + <TabBar + tabs={fileTabs} + activeTabId={activeFileTabId ?? ""} + onSelectTab={setActiveFileTabId} + onCloseTab={handleCloseFileTab} + /> + </div> + </div> - {/* File content */} - <div style={{ flex: 1, overflow: "hidden", paddingBottom: "env(safe-area-inset-bottom)" }}> - {activeFileTab?.filePath ? ( - <FileViewer - filePath={activeFileTab.filePath} - cwd={activeCwd ?? undefined} - sourceSessionId={activeFileTab.sourceSessionId} - gitRefreshKey={explorerRefreshKey} - initialDisplayMode={activeFileTab.initialDisplayMode} - onMentionLines={rightPanelOpen ? handleFileLineMention : undefined} - onOpenFile={(filePath) => handleOpenFile( - filePath, - getFileName(filePath), - { sourceSessionId: activeFileTab.sourceSessionId }, + {/* File content */} + <div style={{ flex: 1, overflow: "hidden", paddingBottom: "env(safe-area-inset-bottom)" }}> + {activeFileTab?.filePath ? ( + <FileViewer + filePath={activeFileTab.filePath} + cwd={activeCwd ?? undefined} + sourceSessionId={activeFileTab.sourceSessionId} + gitRefreshKey={explorerRefreshKey} + initialDisplayMode={activeFileTab.initialDisplayMode} + onMentionLines={rightPanelOpen ? handleFileLineMention : undefined} + onOpenFile={(filePath) => handleOpenFile( + filePath, + getFileName(filePath), + { sourceSessionId: activeFileTab.sourceSessionId }, + )} + /> + ) : ( + <div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 12 }}> + {translate("files.noneOpen")} + </div> )} - /> - ) : ( - <div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 12 }}> - {translate("files.noneOpen")} </div> - )} - </div> + </> + ) : ( + <HtmlPreviewPanel + tabs={htmlPreviewTabs} + activeTabId={activeHtmlPreviewTabId} + onSelectTab={setActiveHtmlPreviewTabId} + onCloseTab={handleCloseHtmlPreviewTab} + view={rightPanelView} + onViewChange={setRightPanelView} + /> + )} </div> </div> {/* File panel toggle — always visible at top-right */} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf3..063b5b8e5 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -6,6 +6,7 @@ import { normalizeCustomPanelLines, parseAnsiLine } from "@/lib/ansi"; import { asBracketedPaste, toTerminalKeyData } from "@/lib/terminal-input"; import { countToolCallBlocks, getAssistantErrorMessage, getDisplayableAssistantBlocks, splitFinalAssistantBlocks } from "@/lib/message-display"; import { MessageView } from "./MessageView"; +import type { HtmlPreviewRequest } from "./HtmlPreviewBlock"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; import { ExtensionStatusBar } from "./ExtensionStatusBar"; @@ -37,6 +38,7 @@ interface Props { onSessionStatsPanelOpen?: () => void; onContextUsageChange?: (usage: { percent: number | null; contextWindow: number; tokens: number | null } | null) => void; onOpenFile?: (filePath: string) => void; + onOpenHtmlPreview?: (request: HtmlPreviewRequest) => void; } function phaseLabel(phase: AgentPhase, t: (key: string, params?: Record<string, string | number>) => string): string | null { @@ -170,7 +172,7 @@ function ProcessDetailsGroup({ messageCount, toolCallCount, children, t }: { mes ); } -export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsChange, onSessionStatsPanelOpen, onContextUsageChange, onOpenFile }: Props) { +export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSessionStatsChange, onSessionStatsPanelOpen, onContextUsageChange, onOpenFile, onOpenHtmlPreview }: Props) { const { t } = useI18n(); const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio(); const isMobile = useIsMobile(); @@ -578,6 +580,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} + onOpenHtmlPreview={onOpenHtmlPreview} entryId={entryIds[idx]} onFork={sessionBusy || isNew || (idx === 0 && msg.role === "user") ? undefined : handleFork} forking={forkingEntryId === entryIds[idx]} @@ -692,7 +695,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate ); })()} {streamState.isStreaming && streamState.streamingMessage && ( - <MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} /> + <MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} onOpenHtmlPreview={onOpenHtmlPreview} /> )} {agentRunning && !streamState.streamingMessage && agentPhase && ( diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx index 9635b4c6d..f5ef836f7 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -1215,7 +1215,8 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL margin: 0, padding: 0, border: 0, - background: "var(--bg)", + // Longhand only: the prism theme already sets backgroundColor. + backgroundColor: "var(--bg)", ...FILE_CODE_STYLE, width: wrapLines ? "100%" : "max-content", minWidth: "100%", diff --git a/components/HtmlPreviewBlock.test.mjs b/components/HtmlPreviewBlock.test.mjs new file mode 100644 index 000000000..d2fac1c9e --- /dev/null +++ b/components/HtmlPreviewBlock.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + jsx: { runtime: "automatic" }, + tsconfigPaths: true, +}); +const { HtmlPreviewBlock, extractHtmlTitle } = await jiti.import("./HtmlPreviewBlock.tsx"); +const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx"); + +function renderBlock(props) { + return renderToStaticMarkup( + React.createElement( + I18nProvider, + null, + React.createElement(HtmlPreviewBlock, props), + ), + ); +} + +test("extractHtmlTitle extracts the document <title>", () => { + assert.equal( + extractHtmlTitle("<!doctype html><html><head><title>My Page"), + "My Page", + ); + assert.equal(extractHtmlTitle(" Hello & World "), "Hello & World"); + assert.equal(extractHtmlTitle("Upper"), "Upper"); + assert.equal(extractHtmlTitle("

no title here

"), ""); + assert.equal(extractHtmlTitle(""), ""); +}); + +test("HtmlPreviewBlock renders an html code block with a preview button", () => { + const html = renderBlock({ code: "

hi

", previewKey: "session:entry:0" }); + + assert.match(html, />Preview HTMLhtmlhi { + const html = renderBlock({ code: "

hi

", previewKey: "session:entry:0", isStreaming: true }); + + assert.match(html, /disabled/); +}); + +test("HtmlPreviewBlock disables the preview button without a previewKey", () => { + const html = renderBlock({ code: "

hi

" }); + + assert.match(html, /disabled/); +}); + +test("HtmlPreviewBlock shows no expand toggle during SSR (collapse is client-side only)", () => { + const html = renderBlock({ code: "

hi

", previewKey: "session:entry:0" }); + + assert.doesNotMatch(html, /Expand/); +}); diff --git a/components/HtmlPreviewBlock.tsx b/components/HtmlPreviewBlock.tsx new file mode 100644 index 000000000..ef4386d32 --- /dev/null +++ b/components/HtmlPreviewBlock.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useLayoutEffect, useRef, useState } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { CodeBlock } from "./MermaidBlock"; + +/** Collapsed height for html code blocks in the chat. */ +export const HTML_CODE_MAX_HEIGHT = 300; + +/** + * A preview request emitted by an html code block. `key` uniquely identifies + * the block (sessionId:entryId:blockIndex) so the right panel can dedupe tabs. + */ +export interface HtmlPreviewRequest { + key: string; + title: string; + html: string; +} + +/** Decode a small set of common HTML entities so titles read naturally. */ +function decodeEntities(text: string): string { + return text + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'|'/gi, "'") + .replace(/ /gi, " "); +} + +/** + * Extract the `` of an HTML document. Returns "" when missing/empty so + * callers can fall back to a localized "untitled" label. + */ +export function extractHtmlTitle(html: string): string { + const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html); + if (!match) return ""; + return decodeEntities(match[1].replace(/<[^>]+>/g, "").trim()); +} + +interface HtmlPreviewBlockProps { + code: string; + isStreaming?: boolean; + /** Stable block identity (sessionId:entryId:blockIndex) for tab dedup. */ + previewKey?: string; + onOpenPreview?: (request: HtmlPreviewRequest) => void; +} + +/** + * HTML code fence with a capped height ("expand full" toggle) and a "preview" + * button that opens the content in the right-side preview panel. + */ +export function HtmlPreviewBlock({ code, isStreaming, previewKey, onOpenPreview }: HtmlPreviewBlockProps) { + const { t } = useI18n(); + const [expanded, setExpanded] = useState(false); + const [overflowing, setOverflowing] = useState(false); + const bodyRef = useRef<HTMLDivElement>(null); + + // Detect whether the code exceeds the collapsed height. When expanded, keep + // the collapse affordance available no matter how the content changes. + useLayoutEffect(() => { + const el = bodyRef.current; + if (!el) return; + setOverflowing(expanded ? true : el.scrollHeight > el.clientHeight + 1); + }, [code, expanded]); + + const openPreview = () => { + if (!previewKey || !onOpenPreview || isStreaming) return; + onOpenPreview({ + key: previewKey, + title: extractHtmlTitle(code) || t("i18n.untitled"), + html: code, + }); + }; + + const previewButton = ( + <button + type="button" + onClick={openPreview} + disabled={isStreaming || !onOpenPreview || !previewKey} + title={isStreaming ? t("i18n.previewAfterStreaming") : t("i18n.previewHtml")} + className="markdown-code-action" + > + {t("i18n.previewHtml")} + </button> + ); + + return ( + <div className="markdown-code-collapse"> + <div ref={bodyRef} className="markdown-code-collapse-body"> + <CodeBlock code={code} lang="html" maxHeight={expanded ? undefined : HTML_CODE_MAX_HEIGHT} headerAction={previewButton} /> + </div> + {overflowing && ( + <> + {!expanded && <div className="markdown-code-fade" aria-hidden="true" />} + <button + type="button" + className="markdown-code-expand" + onClick={() => setExpanded((v) => !v)} + > + {expanded ? t("i18n.collapseCode") : t("i18n.expandCode")} + </button> + </> + )} + </div> + ); +} diff --git a/components/HtmlPreviewPanel.tsx b/components/HtmlPreviewPanel.tsx new file mode 100644 index 000000000..e3e51cba5 --- /dev/null +++ b/components/HtmlPreviewPanel.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { RightPanelViewSwitcher } from "./RightPanelViewSwitcher"; + +export interface HtmlPreviewTab { + id: string; + title: string; + html: string; +} + +interface HtmlPreviewPanelProps { + tabs: HtmlPreviewTab[]; + activeTabId: string | null; + onSelectTab: (id: string) => void; + onCloseTab: (id: string) => void; + view: "files" | "preview"; + onViewChange: (view: "files" | "preview") => void; +} + +/** Fixed width of every preview tab, per design. */ +const PREVIEW_TAB_WIDTH = 150; + +/** + * Right-side panel view for rendered HTML previews. Each chat html code block + * becomes one tab (title = document <title>, "untitled" fallback). Content runs + * in a sandboxed iframe so it cannot touch the host page. + */ +export function HtmlPreviewPanel({ tabs, activeTabId, onSelectTab, onCloseTab, view, onViewChange }: HtmlPreviewPanelProps) { + const { t } = useI18n(); + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; + const [loaded, setLoaded] = useState(false); + + // A new tab remounts the iframe — reset the loading flag. + useEffect(() => { + setLoaded(false); + }, [activeTabId]); + + return ( + <div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}> + {/* Preview tabs row with view switcher */} + <div + style={{ + display: "flex", + alignItems: "flex-end", + background: "var(--bg-panel)", + overflowX: "auto", + flexShrink: 0, + height: "calc(36px + env(safe-area-inset-top))", + paddingTop: "env(safe-area-inset-top)", + }} + > + <div style={{ alignSelf: "center" }}> + <RightPanelViewSwitcher + view={view} + onViewChange={onViewChange} + previewAvailable + /> + </div> + {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + return ( + <div + key={tab.id} + onClick={() => onSelectTab(tab.id)} + onAuxClick={(e) => { + if (e.button !== 1) return; + e.preventDefault(); + e.stopPropagation(); + onCloseTab(tab.id); + }} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + height: 36, + paddingLeft: 12, + paddingRight: 6, + borderRight: "1px solid var(--border)", + background: isActive ? "var(--bg)" : "var(--bg-panel)", + cursor: "pointer", + fontSize: 12, + color: isActive ? "var(--text)" : "var(--text-muted)", + whiteSpace: "nowrap", + width: PREVIEW_TAB_WIDTH, + flexShrink: 0, + userSelect: "none", + transition: "background 0.1s, color 0.1s", + }} + title={tab.title} + > + <span style={{ flexShrink: 0, opacity: isActive ? 1 : 0.7, display: "flex", alignItems: "center" }}> + <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> + <path d="M4 5v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2z" /> + <path d="M14 4v5M10 4v5M4 11h16" /> + </svg> + </span> + <span style={{ overflow: "hidden", textOverflow: "ellipsis", flex: 1, fontWeight: isActive ? 500 : 400 }}> + {tab.title} + </span> + <button + onClick={(e) => { e.stopPropagation(); onCloseTab(tab.id); }} + style={{ + display: "flex", alignItems: "center", justifyContent: "center", + width: 24, height: 24, + background: "transparent", + border: "none", + borderRadius: 4, + color: "var(--text-dim)", + cursor: "pointer", + padding: 0, + flexShrink: 0, + }} + title={t("i18n.close")} + aria-label={`${t("i18n.close")} ${tab.title}`} + > + <svg width="11" height="11" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden="true"> + <line x1="2" y1="2" x2="8" y2="8" /> + <line x1="8" y1="2" x2="2" y2="8" /> + </svg> + </button> + </div> + ); + })} + </div> + + {activeTab ? ( + <> + {/* Sandboxed preview iframe */} + <div style={{ position: "relative", flex: 1, minHeight: 0, background: "#fff" }}> + {!loaded && ( + <div style={{ + position: "absolute", + inset: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "var(--text-dim)", + fontSize: 12, + pointerEvents: "none", + }}> + {t("i18n.loadingPreview")} + </div> + )} + <iframe + key={activeTab.id} + srcDoc={activeTab.html} + sandbox="allow-scripts allow-popups allow-modals" + onLoad={() => setLoaded(true)} + title={activeTab.title} + style={{ position: "absolute", inset: 0, width: "100%", height: "100%", border: "none" }} + /> + </div> + </> + ) : ( + <div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 12 }}> + {t("i18n.emptyHtmlPreview")} + </div> + )} + </div> + ); +} diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index d83279d36..963fa554f 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -6,6 +6,7 @@ import { resolveLocalFileHref } from "@/lib/file-links"; import { encodeFilePathForApi } from "@/lib/file-paths"; import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown"; import { MermaidBlock, CodeBlock } from "./MermaidBlock"; +import { HtmlPreviewBlock, type HtmlPreviewRequest } from "./HtmlPreviewBlock"; interface MarkdownBodyProps { children: string; @@ -13,9 +14,12 @@ interface MarkdownBodyProps { isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; + /** Stable block identity for html preview tabs (sessionId:entryId:blockIndex). */ + previewKey?: string; + onOpenHtmlPreview?: (request: HtmlPreviewRequest) => void; } -export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) { +export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile, previewKey, onOpenHtmlPreview }: MarkdownBodyProps) { const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]); // Stable renderer identities keep stateful blocks mounted across message hover updates. const components = useMemo<Components>(() => ({ @@ -27,6 +31,9 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile if (lang === "mermaid") { return <MermaidBlock code={raw.replace(/\n$/, "")} isStreaming={isStreaming} defaultPreview />; } + if (lang === "html") { + return <HtmlPreviewBlock code={raw.replace(/\n$/, "")} isStreaming={isStreaming} previewKey={previewKey} onOpenPreview={onOpenHtmlPreview} />; + } return <CodeBlock code={raw.replace(/\n$/, "")} lang={lang} />; } return ( @@ -86,7 +93,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile </div> ); }, - }), [cwd, isStreaming, onOpenFile]); + }), [cwd, isStreaming, onOpenFile, previewKey, onOpenHtmlPreview]); return ( <div className={["markdown-body", className].filter(Boolean).join(" ")}> diff --git a/components/MermaidBlock.tsx b/components/MermaidBlock.tsx index b3e204907..a4934d5de 100644 --- a/components/MermaidBlock.tsx +++ b/components/MermaidBlock.tsx @@ -134,13 +134,15 @@ interface CodeBlockProps { code: string; lang: string; headerAction?: ReactNode; + /** Optional cap on the code body height (scrollable). */ + maxHeight?: number; } /** * Syntax-highlighted code block with copy button. * Used as the "source" view for mermaid blocks and for all non-mermaid code fences. */ -export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) { +export function CodeBlock({ code, lang, headerAction, maxHeight }: CodeBlockProps) { const { isDark } = useTheme(); const { t } = useI18n(); const [copied, setCopied] = useState(false); @@ -166,6 +168,7 @@ export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) { </button> </div> </div> + <div style={maxHeight ? { maxHeight, overflow: "hidden" } : undefined}> <SyntaxHighlighter language={lang || "text"} style={isDark ? vscDarkPlus : vs} @@ -177,12 +180,15 @@ export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) { fontSize: 12.5, lineHeight: 1.62, borderRadius: 0, - background: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))", + // Longhand only: the prism theme already sets backgroundColor, and + // mixing shorthand `background` with it triggers React style warnings. + backgroundColor: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))", }} codeTagProps={{ style: { fontFamily: "var(--font-mono)" } }} > {code} </SyntaxHighlighter> + </div> </div> ); } diff --git a/components/MessageView.tsx b/components/MessageView.tsx index d73bee37f..7bb2c4b80 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -2,6 +2,7 @@ import { memo, useState, useRef, useEffect, useMemo } from "react"; import { MarkdownBody } from "./MarkdownBody"; +import type { HtmlPreviewRequest } from "./HtmlPreviewBlock"; import { copyText } from "@/lib/clipboard"; import { useI18n } from "@/hooks/useI18n"; import { parseCompactionSummary } from "@/lib/compaction-summary"; @@ -60,6 +61,7 @@ interface Props { modelNames?: Record<string, string>; cwd?: string; onOpenFile?: (filePath: string) => void; + onOpenHtmlPreview?: (request: HtmlPreviewRequest) => void; entryId?: string; onFork?: (entryId: string) => void; forking?: boolean; @@ -98,12 +100,12 @@ function haveSameRelevantToolResults( return true; } -export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) { +export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onOpenHtmlPreview, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) { if (message.role === "user") { - return <UserMessageView message={message as UserMessage} cwd={cwd} onOpenFile={onOpenFile} entryId={entryId} onFork={onFork} forking={forking} onNavigate={onNavigate} prevAssistantEntryId={prevAssistantEntryId} onEditContent={onEditContent} />; + return <UserMessageView message={message as UserMessage} cwd={cwd} onOpenFile={onOpenFile} onOpenHtmlPreview={onOpenHtmlPreview} entryId={entryId} onFork={onFork} forking={forking} onNavigate={onNavigate} prevAssistantEntryId={prevAssistantEntryId} onEditContent={onEditContent} />; } if (message.role === "assistant") { - return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} cwd={cwd} onOpenFile={onOpenFile} showTimestamp={showTimestamp} prevTimestamp={prevTimestamp} sessionId={sessionId} entryId={entryId} />; + return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} cwd={cwd} onOpenFile={onOpenFile} onOpenHtmlPreview={onOpenHtmlPreview} showTimestamp={showTimestamp} prevTimestamp={prevTimestamp} sessionId={sessionId} entryId={entryId} />; } if (message.role === "toolResult") { // Rendered inline under its toolCall — skip standalone rendering if paired @@ -126,6 +128,7 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too && prev.modelNames === next.modelNames && prev.cwd === next.cwd && prev.onOpenFile === next.onOpenFile + && prev.onOpenHtmlPreview === next.onOpenHtmlPreview && prev.entryId === next.entryId && prev.onFork === next.onFork && prev.forking === next.forking @@ -137,10 +140,11 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too && prev.sessionId === next.sessionId; }); -function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: { +function UserMessageView({ message, cwd, onOpenFile, onOpenHtmlPreview, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: { message: UserMessage; cwd?: string; onOpenFile?: (filePath: string) => void; + onOpenHtmlPreview?: (request: HtmlPreviewRequest) => void; entryId?: string; onFork?: (entryId: string) => void; forking?: boolean; @@ -222,7 +226,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o })} </div> )} - {content && <MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>} + {content && <MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile} previewKey={entryId ? `${entryId}:0` : undefined} onOpenHtmlPreview={onOpenHtmlPreview}>{content}</MarkdownBody>} </div> </div> @@ -345,6 +349,7 @@ function AssistantMessageView({ modelNames, cwd, onOpenFile, + onOpenHtmlPreview, showTimestamp, prevTimestamp, sessionId, @@ -356,6 +361,7 @@ function AssistantMessageView({ modelNames?: Record<string, string>; cwd?: string; onOpenFile?: (filePath: string) => void; + onOpenHtmlPreview?: (request: HtmlPreviewRequest) => void; showTimestamp?: boolean; prevTimestamp?: number; sessionId?: string; @@ -529,7 +535,7 @@ function AssistantMessageView({ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}> {blockItems.map(({ block, originalIndex }) => ( - <BlockView key={`${entryId ?? "stream"}-${originalIndex}`} block={block} toolResults={toolResults} isStreaming={isStreaming} streamingDuration={streamingDurations.get(originalIndex) ?? (block.type === "thinking" ? thinkingDurationFromFile : undefined)} toolCallDurations={toolCallDurations} cwd={cwd} onOpenFile={onOpenFile} sessionId={sessionId} entryId={entryId} blockIndex={originalIndex} /> + <BlockView key={`${entryId ?? "stream"}-${originalIndex}`} block={block} toolResults={toolResults} isStreaming={isStreaming} streamingDuration={streamingDurations.get(originalIndex) ?? (block.type === "thinking" ? thinkingDurationFromFile : undefined)} toolCallDurations={toolCallDurations} cwd={cwd} onOpenFile={onOpenFile} onOpenHtmlPreview={onOpenHtmlPreview} sessionId={sessionId} entryId={entryId} blockIndex={originalIndex} /> ))} </div> @@ -603,9 +609,10 @@ function AssistantMessageView({ ); } -function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map<string, ToolResultMessage>; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map<string, number>; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) { +function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, onOpenHtmlPreview, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map<string, ToolResultMessage>; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map<string, number>; cwd?: string; onOpenFile?: (filePath: string) => void; onOpenHtmlPreview?: (request: HtmlPreviewRequest) => void; sessionId?: string; entryId?: string; blockIndex: number }) { if (block.type === "text") { - return <TextBlock block={block as TextContent} isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} />; + const previewKey = entryId ? `${entryId}:${blockIndex}` : undefined; + return <TextBlock block={block as TextContent} isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} previewKey={previewKey} onOpenHtmlPreview={onOpenHtmlPreview} />; } if (block.type === "thinking") { return <ThinkingBlock block={block as ThinkingContent} duration={streamingDuration} sessionId={sessionId} entryId={entryId} blockIndex={blockIndex} />; @@ -619,8 +626,8 @@ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCal return null; } -function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) { - return <MarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile}>{block.text}</MarkdownBody>; +function TextBlock({ block, isStreaming, cwd, onOpenFile, previewKey, onOpenHtmlPreview }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; previewKey?: string; onOpenHtmlPreview?: (request: HtmlPreviewRequest) => void }) { + return <MarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} previewKey={previewKey} onOpenHtmlPreview={onOpenHtmlPreview}>{block.text}</MarkdownBody>; } function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { diff --git a/components/MobilePwaLayout.test.mjs b/components/MobilePwaLayout.test.mjs index 12204cead..f322ad90f 100644 --- a/components/MobilePwaLayout.test.mjs +++ b/components/MobilePwaLayout.test.mjs @@ -22,7 +22,7 @@ test("tracks the visual viewport while the software keyboard is open", () => { assert.match(appShellSource, /paddingLeft: "env\(safe-area-inset-left\)"/); assert.match(appShellSource, /paddingRight: "env\(safe-area-inset-right\)"/); assert.match(appShellSource, /height: "calc\(36px \+ env\(safe-area-inset-top\)\)"/); - assert.match(appShellSource, /\/\* Right panel tab bar \*\/[\s\S]*?height: "calc\(36px \+ env\(safe-area-inset-top\)\)"/); + assert.match(appShellSource, /\/\* Right panel tab bar with view switcher — same height as the main top bar \*\/[\s\S]*?height: "calc\(36px \+ env\(safe-area-inset-top\)\)"/); assert.match(appShellSource, /height: "var\(--app-viewport-height, 100dvh\)"/); assert.match(appShellSource, /right: "env\(safe-area-inset-right\)"/); assert.match(viewportHookSource, /window\.visualViewport/); diff --git a/components/RightPanelViewSwitcher.test.mjs b/components/RightPanelViewSwitcher.test.mjs new file mode 100644 index 000000000..2ad349a24 --- /dev/null +++ b/components/RightPanelViewSwitcher.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + jsx: { runtime: "automatic" }, + tsconfigPaths: true, +}); +const { RightPanelViewSwitcher } = await jiti.import("./RightPanelViewSwitcher.tsx"); +const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx"); + +function renderSwitcher(props) { + return renderToStaticMarkup( + React.createElement( + I18nProvider, + null, + React.createElement(RightPanelViewSwitcher, props), + ), + ); +} + +test("renders both icon buttons when preview is available", () => { + const html = renderSwitcher({ view: "files", onViewChange: () => {}, previewAvailable: true }); + + assert.match(html, /aria-label="Files"/); + assert.match(html, /aria-label="HTML preview"/); + // SVG line icons present (folder + eye) + assert.match(html, /<svg/); + assert.match(html, /M3 7a2 2 0 0 1 2-2h4l2 2h8/); // folder + assert.match(html, /M1 12s4-8 11-8/); // eye +}); + +test("marks the active view as is-active", () => { + const filesActive = renderSwitcher({ view: "files", onViewChange: () => {}, previewAvailable: true }); + const previewActive = renderSwitcher({ view: "preview", onViewChange: () => {}, previewAvailable: true }); + + assert.match(filesActive, /aria-pressed="true"/); + assert.match(previewActive, /aria-pressed="true"/); +}); + +test("hides the preview button until preview is available", () => { + const html = renderSwitcher({ view: "files", onViewChange: () => {}, previewAvailable: false }); + + assert.match(html, /aria-label="Files"/); + assert.doesNotMatch(html, /aria-label="HTML preview"/); +}); diff --git a/components/RightPanelViewSwitcher.tsx b/components/RightPanelViewSwitcher.tsx new file mode 100644 index 000000000..9175d3ae3 --- /dev/null +++ b/components/RightPanelViewSwitcher.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useI18n } from "@/hooks/useI18n"; + +interface RightPanelViewSwitcherProps { + view: "files" | "preview"; + onViewChange: (view: "files" | "preview") => void; + /** Preview button only appears once at least one preview tab exists. */ + previewAvailable: boolean; +} + +/** + * Icon-only view switcher for the right panel (files | html preview). + * SVG line icons matching the app's icon style; active view uses --accent. + */ +export function RightPanelViewSwitcher({ view, onViewChange, previewAvailable }: RightPanelViewSwitcherProps) { + const { t } = useI18n(); + return ( + <div style={{ display: "flex", alignItems: "center", gap: 2, paddingLeft: 8, paddingRight: 8, flexShrink: 0 }}> + <button + type="button" + onClick={() => onViewChange("files")} + className={["right-panel-view-switch", view === "files" ? "is-active" : ""].filter(Boolean).join(" ")} + aria-pressed={view === "files"} + title={t("i18n.filesView")} + aria-label={t("i18n.filesView")} + > + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> + <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> + </svg> + </button> + {previewAvailable && ( + <button + type="button" + onClick={() => onViewChange("preview")} + className={["right-panel-view-switch", view === "preview" ? "is-active" : ""].filter(Boolean).join(" ")} + aria-pressed={view === "preview"} + title={t("i18n.htmlPreview")} + aria-label={t("i18n.htmlPreview")} + > + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> + <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> + <circle cx="12" cy="12" r="3" /> + </svg> + </button> + )} + </div> + ); +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 9d8099adf..18d1b4dc1 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -313,6 +313,13 @@ export const enLocale: LocalePlugin = { "i18n.enableWrap": "Enable word wrap", "i18n.htmlPreview": "HTML preview", "i18n.previewFile": "Preview {file}", + "i18n.filesView": "Files", + "i18n.untitled": "Untitled", + "i18n.previewHtml": "Preview HTML", + "i18n.expandCode": "Expand", + "i18n.collapseCode": "Collapse", + "i18n.loadingPreview": "Loading preview…", + "i18n.emptyHtmlPreview": "No HTML previews open", "i18n.invalidMermaid": "Invalid Mermaid diagram", "i18n.renderingMermaid": "Rendering Mermaid diagram", "i18n.previewAfterStreaming": "Preview available after streaming", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 42054a98d..b9c4a65f0 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -313,6 +313,13 @@ export const zhCNLocale: LocalePlugin = { "i18n.enableWrap": "启用自动换行", "i18n.htmlPreview": "HTML 预览", "i18n.previewFile": "预览 {file}", + "i18n.filesView": "文件", + "i18n.untitled": "无标题", + "i18n.previewHtml": "预览 HTML", + "i18n.expandCode": "展开全文", + "i18n.collapseCode": "收起", + "i18n.loadingPreview": "正在加载预览…", + "i18n.emptyHtmlPreview": "没有打开的 HTML 预览", "i18n.invalidMermaid": "Mermaid 图表无效", "i18n.renderingMermaid": "正在渲染 Mermaid 图表", "i18n.previewAfterStreaming": "流式输出完成后可预览", diff --git a/package-lock.json b/package-lock.json index 99ca9d023..dcf762edf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ant-design/fast-color": "^3.0.0" } @@ -100,6 +101,7 @@ "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", @@ -116,6 +118,7 @@ "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8.x" } @@ -126,6 +129,7 @@ "integrity": "sha512-zlJtE7AMbG12TeYVPhtBXwNpFInNy8mjLzcIm+0BPw16/b8ODG87YJ1G37VIF5VFscdgfsf6EweAFPTobu/3iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.4.2", @@ -145,7 +149,8 @@ "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@ant-design/react-slick": { "version": "2.0.0", @@ -153,6 +158,7 @@ "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", @@ -661,7 +667,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -909,6 +914,7 @@ "integrity": "sha512-4USBWz++DUSLTuIYpbYkSgy1F9ZmNG9S/lXvlUN6qMK0P0RlW+6eQmDUB4DgZ7HVvtXl4pvi4z5J2fv6Z3+9hg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@base-ui/utils": "0.2.3", @@ -942,6 +948,7 @@ "integrity": "sha512-/CguQ2PDaOzeVOkllQR8nocJ0FFIDqsWIcURsVmm53QGo8NhFNpePjNlyPIB41luxfOqnG7PU0xicMEw3ls7XQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", @@ -979,6 +986,7 @@ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.0" }, @@ -1009,6 +1017,7 @@ "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" @@ -1024,6 +1033,7 @@ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" @@ -1039,6 +1049,7 @@ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.0" }, @@ -1867,7 +1878,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2912,7 +2922,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -2977,7 +2986,8 @@ "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@emoji-mart/react": { "version": "1.1.1", @@ -2985,6 +2995,7 @@ "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "emoji-mart": "^5.2", "react": "^16.8 || ^17 || ^18" @@ -3441,6 +3452,7 @@ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/utils": "^0.2.11" } @@ -3451,6 +3463,7 @@ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -3462,6 +3475,7 @@ "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", @@ -3478,6 +3492,7 @@ "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/dom": "^1.7.6" }, @@ -3491,13 +3506,15 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@giscus/react": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@giscus/react/-/react-3.1.0.tgz", "integrity": "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg==", "dev": true, + "peer": true, "dependencies": { "giscus": "^1.6.0" }, @@ -4122,7 +4139,8 @@ "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@lit/reactive-element": { "version": "2.1.2", @@ -4130,6 +4148,7 @@ "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } @@ -4139,7 +4158,8 @@ "resolved": "https://registry.npmjs.org/@lobehub/emojilib/-/emojilib-1.0.0.tgz", "integrity": "sha512-s9KnjaPjsEefaNv150G3aifvB+J3P4eEKG+epY9zDPS2BeB6+V2jELWqAZll+nkogMaVovjEE813z3V751QwGw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@lobehub/fluent-emoji": { "version": "4.1.0", @@ -4147,6 +4167,7 @@ "integrity": "sha512-R1MB2lfUkDvB7XAQdRzY75c1dx/tB7gEvBPaEEMarzKfCJWmXm7rheS6caVzmgwAlq5sfmTbxPL+un99sp//Yw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@lobehub/emojilib": "^1.0.0", "antd-style": "^4.1.0", @@ -4165,7 +4186,8 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@lobehub/icons": { "version": "5.6.0", @@ -4173,7 +4195,6 @@ "integrity": "sha512-wxDk6kqOJ5ctoyHbi0zNDZkpgCW3uxtWM8iA4ZZVqcKvL8xQxeWBO+0IzOtDcSij68xhKJkjcBEVTg+IFk4VHg==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -4206,6 +4227,7 @@ "integrity": "sha512-BYUs66/djw2DaFMlfy71v6uShJvJix5O4dv0geQjgeBwXo/3hFIagKEEJHkFTxrAdoTCxAmT2XwRhQSW3mgbag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@base-ui/react": "1.0.0", @@ -4291,6 +4313,7 @@ "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", "dev": true, "license": "ISC", + "peer": true, "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -4301,6 +4324,7 @@ "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -4318,6 +4342,7 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", + "peer": true, "bin": { "uuid": "dist-node/bin/uuid" } @@ -4328,6 +4353,7 @@ "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", @@ -4366,6 +4392,7 @@ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">= 12" } @@ -4376,6 +4403,7 @@ "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/mdx": "^2.0.0" }, @@ -4628,7 +4656,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -4648,6 +4675,7 @@ "integrity": "sha512-lLi+3sLCm3QDd5/aLO9pw+WbF6UzhrkWm2oTZ5WZJTGemOyUNRJ4DDhcEKmVusu4C4bXx9Nssh6fF+wQcapb5w==", "dev": true, "license": "apache-2.0", + "peer": true, "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", @@ -4667,6 +4695,7 @@ "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", @@ -4680,6 +4709,7 @@ "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", @@ -4692,6 +4722,7 @@ "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" @@ -4703,6 +4734,7 @@ "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "3.23.0" } @@ -4713,6 +4745,7 @@ "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "3.23.0" } @@ -4723,6 +4756,7 @@ "integrity": "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" @@ -4734,6 +4768,7 @@ "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" @@ -4745,6 +4780,7 @@ "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", @@ -4762,6 +4798,7 @@ "integrity": "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "vscode": "^1.0.0" } @@ -4772,6 +4809,7 @@ "integrity": "sha512-E0eMV8nXexrs7Vro7PdS8v/JfvvYCMh8HN6CXJ9l8fk9atZaY05fVUcyiAh5KjEJu7IxdFy4URfHGpM7+iOl1A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "object-assign": "^4.1.1" } @@ -4838,7 +4876,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", @@ -4846,6 +4885,7 @@ "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, @@ -4870,6 +4910,7 @@ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-slot": "1.2.3" }, @@ -4894,6 +4935,7 @@ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -4913,6 +4955,7 @@ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -4929,6 +4972,7 @@ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -4945,6 +4989,7 @@ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -4973,6 +5018,7 @@ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-slot": "1.2.3" }, @@ -4997,6 +5043,7 @@ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -5016,6 +5063,7 @@ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, @@ -5035,6 +5083,7 @@ "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", @@ -5068,6 +5117,7 @@ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-slot": "1.2.3" }, @@ -5092,6 +5142,7 @@ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -5111,6 +5162,7 @@ "integrity": "sha512-4kY9IVa6+9nJPsYmngK5Uk2kUmZnv7ChhHAFeQ5oaj8jrR1bIi3xww8nH71pz1/Ve4d/cXO3YxT8eikt1B0a8w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -5136,6 +5188,7 @@ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -5161,6 +5214,7 @@ "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-slot": "1.2.4" }, @@ -5185,6 +5239,7 @@ "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -5204,6 +5259,7 @@ "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -5239,6 +5295,7 @@ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -5264,6 +5321,7 @@ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-slot": "1.2.3" }, @@ -5288,6 +5346,7 @@ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -5307,6 +5366,7 @@ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -5323,6 +5383,7 @@ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -5343,6 +5404,7 @@ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, @@ -5362,6 +5424,7 @@ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, @@ -5381,6 +5444,7 @@ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -5397,6 +5461,7 @@ "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/rect": "1.1.1" }, @@ -5416,6 +5481,7 @@ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, @@ -5435,6 +5501,7 @@ "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, @@ -5459,6 +5526,7 @@ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-slot": "1.2.3" }, @@ -5483,6 +5551,7 @@ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -5501,7 +5570,8 @@ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@rc-component/async-validator": { "version": "5.1.0", @@ -5509,6 +5579,7 @@ "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.24.4" }, @@ -5522,6 +5593,7 @@ "integrity": "sha512-Ip9356xwZUR2nbW5PRVGif4B/bDve4pLa/N+PGbvBaTnjbvmN4PFMBGQSmlDlzKP1ovxaYMvwF/dI9lXNLT4iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/select": "~1.6.0", "@rc-component/tree": "~1.2.0", @@ -5539,6 +5611,7 @@ "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" @@ -5554,6 +5627,7 @@ "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/motion": "^1.1.4", @@ -5571,6 +5645,7 @@ "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ant-design/fast-color": "^3.0.1", "@rc-component/util": "^1.3.0", @@ -5587,6 +5662,7 @@ "integrity": "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.3.0" }, @@ -5601,6 +5677,7 @@ "integrity": "sha512-Ay6PM7phkTkquplG8fWfUGFZ2GTLx9diTl4f0d8Eqxd7W1u1KjE9AQooFQHOHnhZf0Ya3z51+5EKCWHmt/dNEw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/motion": "^1.1.3", "@rc-component/portal": "^2.1.0", @@ -5618,6 +5695,7 @@ "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.1.3", @@ -5635,6 +5713,7 @@ "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.2.1", @@ -5651,6 +5730,7 @@ "integrity": "sha512-8O7TB55Fi2mWIGvSnwZjk8jFqVNYyKDAswglwGShcbndxqzKz4cHwNtNaLjZlAeRge9wcB0LL8IWsC/Bl18raQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/async-validator": "^5.1.0", "@rc-component/util": "^1.6.2", @@ -5670,6 +5750,7 @@ "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", @@ -5687,6 +5768,7 @@ "integrity": "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" @@ -5702,6 +5784,7 @@ "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/mini-decimal": "^1.0.1", "@rc-component/util": "^1.4.0", @@ -5718,6 +5801,7 @@ "integrity": "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/input": "~1.1.0", "@rc-component/menu": "~1.2.0", @@ -5737,6 +5821,7 @@ "integrity": "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", @@ -5755,6 +5840,7 @@ "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.0" }, @@ -5768,6 +5854,7 @@ "integrity": "sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.2.0", "clsx": "^2.1.1" @@ -5783,6 +5870,7 @@ "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.2.0" }, @@ -5800,6 +5888,7 @@ "integrity": "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.2.1", @@ -5819,6 +5908,7 @@ "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/resize-observer": "^1.0.1", @@ -5836,6 +5926,7 @@ "integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" @@ -5851,6 +5942,7 @@ "integrity": "sha512-9FBYYsvH3HMLICaPDA/1Th5FLaDkFa7qAtangIdlhKb3ZALaR745e9PsOhheJb6asS4QXc12ffiAcjdkZ4C5/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", @@ -5890,6 +5982,7 @@ "integrity": "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" @@ -5908,6 +6001,7 @@ "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" @@ -5923,6 +6017,7 @@ "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.24.7" }, @@ -5940,6 +6035,7 @@ "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" @@ -5958,6 +6054,7 @@ "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.2.0" }, @@ -5972,6 +6069,7 @@ "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/motion": "^1.1.4", @@ -5989,6 +6087,7 @@ "integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", @@ -6010,6 +6109,7 @@ "integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" @@ -6028,6 +6128,7 @@ "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" @@ -6046,6 +6147,7 @@ "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" @@ -6061,6 +6163,7 @@ "integrity": "sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", @@ -6082,6 +6185,7 @@ "integrity": "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.2.0", @@ -6104,6 +6208,7 @@ "integrity": "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/input": "~1.1.0", "@rc-component/resize-observer": "^1.0.0", @@ -6121,6 +6226,7 @@ "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/trigger": "^3.7.1", "@rc-component/util": "^1.3.0", @@ -6137,6 +6243,7 @@ "integrity": "sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", @@ -6157,6 +6264,7 @@ "integrity": "sha512-5Gli43+m4R7NhpYYz3Z61I6LOw9yI6CNChxgVtvrO6xB1qML7iE6QMLVMB3+FTjo2yF6uFdAHtqWPECz/zbX5w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.8.1", @@ -6177,6 +6285,7 @@ "integrity": "sha512-iYsPq3nuLYvGqdvFAW+l+I9ASRIOVbMXyA8FGZg2lGym/GwkaWeJGzI4eJ7c9IOEhRj0oyfIN4S92Fl3J05mjQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/select": "~1.6.0", "@rc-component/tree": "~1.2.0", @@ -6194,6 +6303,7 @@ "integrity": "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.2.0", @@ -6215,6 +6325,7 @@ "integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" @@ -6252,6 +6363,7 @@ "integrity": "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@rc-component/resize-observer": "^1.0.1", @@ -6279,6 +6391,7 @@ "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", @@ -6296,6 +6409,7 @@ "integrity": "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", @@ -6311,6 +6425,7 @@ "integrity": "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" @@ -6325,6 +6440,7 @@ "integrity": "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "4.0.2" }, @@ -6338,6 +6454,7 @@ "integrity": "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", @@ -6353,6 +6470,7 @@ "integrity": "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "4.0.2" }, @@ -6366,6 +6484,7 @@ "integrity": "sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/types": "4.0.2" @@ -6380,6 +6499,7 @@ "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" @@ -6393,7 +6513,8 @@ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@smithy/core": { "version": "3.31.1", @@ -6519,6 +6640,7 @@ "resolved": "https://registry.npmjs.org/@splinetool/runtime/-/runtime-0.9.526.tgz", "integrity": "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ==", "dev": true, + "peer": true, "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" @@ -6530,6 +6652,7 @@ "integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": ">= 16.3.0" } @@ -7222,7 +7345,8 @@ "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -7260,7 +7384,8 @@ "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/ms": { "version": "2.1.0", @@ -7308,7 +7433,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7319,7 +7443,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -7399,7 +7522,6 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -7890,7 +8012,8 @@ "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@use-gesture/react": { "version": "10.3.1", @@ -7898,6 +8021,7 @@ "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@use-gesture/core": "10.3.1" }, @@ -7921,7 +8045,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7954,6 +8077,7 @@ "integrity": "sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.21.0", "@types/js-cookie": "^3.0.6", @@ -8257,6 +8381,7 @@ "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8274,6 +8399,7 @@ "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "astring": "bin/astring" } @@ -8294,6 +8420,7 @@ "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -8471,7 +8598,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8649,7 +8775,8 @@ "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-3.2.0.tgz", "integrity": "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw==", "dev": true, - "license": "(BSD-3-Clause AND Apache-2.0)" + "license": "(BSD-3-Clause AND Apache-2.0)", + "peer": true }, "node_modules/class-variance-authority": { "version": "0.7.1", @@ -8657,6 +8784,7 @@ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "clsx": "^2.1.1" }, @@ -8669,7 +8797,8 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/client-only": { "version": "0.0.1", @@ -8693,6 +8822,7 @@ "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" @@ -8723,7 +8853,8 @@ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/comma-separated-tokens": { "version": "2.0.3", @@ -8751,7 +8882,8 @@ "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/concat-map": { "version": "0.0.1", @@ -8846,7 +8978,6 @@ "integrity": "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -9291,7 +9422,6 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -9507,6 +9637,7 @@ "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.16" } @@ -9602,7 +9733,9 @@ "version": "8.0.3", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.3.1" } @@ -9925,6 +10058,7 @@ "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", @@ -9942,6 +10076,7 @@ "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", @@ -9982,7 +10117,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10123,7 +10257,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10503,6 +10636,7 @@ "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" }, @@ -10517,6 +10651,7 @@ "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", @@ -10545,6 +10680,7 @@ "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" @@ -10560,6 +10696,7 @@ "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", @@ -10576,6 +10713,7 @@ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">= 12" } @@ -10586,6 +10724,7 @@ "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" @@ -10601,6 +10740,7 @@ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -10627,6 +10767,7 @@ "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-extendable": "^0.1.0" }, @@ -10640,6 +10781,7 @@ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10761,6 +10903,7 @@ "integrity": "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.3" }, @@ -10787,6 +10930,7 @@ "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.16" }, @@ -10861,6 +11005,7 @@ "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -10892,6 +11037,7 @@ "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", @@ -11091,6 +11237,7 @@ "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11101,6 +11248,7 @@ "integrity": "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lit": "^3.2.1" } @@ -11469,6 +11617,7 @@ "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", @@ -11498,6 +11647,7 @@ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", @@ -11722,6 +11872,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -11740,6 +11891,7 @@ "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11817,7 +11969,8 @@ "integrity": "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==", "deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/is-alphabetical": { "version": "2.0.1", @@ -12027,6 +12180,7 @@ "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-plain-object": "^2.0.4" }, @@ -12183,6 +12337,7 @@ "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "isobject": "^3.0.1" }, @@ -12355,6 +12510,7 @@ "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12392,7 +12548,8 @@ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/js-tokens": { "version": "4.0.0", @@ -12476,6 +12633,7 @@ "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "string-convert": "^0.2.0" } @@ -12609,6 +12767,7 @@ "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -12925,6 +13084,7 @@ "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -12937,6 +13097,7 @@ "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", @@ -12949,6 +13110,7 @@ "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@types/trusted-types": "^2.0.2" } @@ -12974,7 +13136,8 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash-es": { "version": "4.18.1", @@ -13052,7 +13215,8 @@ "resolved": "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz", "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lucide-react": { "version": "0.562.0", @@ -13060,6 +13224,7 @@ "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", "dev": true, "license": "ISC", + "peer": true, "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -13115,6 +13280,7 @@ "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16" }, @@ -13343,6 +13509,7 @@ "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", @@ -13424,6 +13591,7 @@ "integrity": "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" @@ -13512,6 +13680,7 @@ "integrity": "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "get-value": "^2.0.6", "is-extendable": "^1.0.0", @@ -13605,7 +13774,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", @@ -13667,6 +13835,7 @@ "integrity": "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "3.0.1", @@ -13693,6 +13862,7 @@ "integrity": "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "get-east-asian-width": "^1.4.0", "micromark-util-character": "^2.1.1", @@ -13871,6 +14041,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", @@ -13888,6 +14059,7 @@ "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", @@ -13911,6 +14083,7 @@ "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "micromark-util-types": "^2.0.0" }, @@ -13925,6 +14098,7 @@ "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", @@ -13946,6 +14120,7 @@ "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", @@ -14023,6 +14198,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", @@ -14262,6 +14438,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", @@ -14454,6 +14631,7 @@ "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "for-in": "^1.0.2", "is-extendable": "^1.0.1" @@ -14481,6 +14659,7 @@ "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" @@ -14508,6 +14687,7 @@ "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "motion-utils": "^12.36.0" } @@ -14517,7 +14697,8 @@ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/ms": { "version": "2.1.3", @@ -14717,6 +14898,7 @@ "integrity": "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -14850,6 +15032,7 @@ "integrity": "sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, @@ -14862,7 +15045,8 @@ "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/oniguruma-to-es": { "version": "4.3.6", @@ -14870,6 +15054,7 @@ "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", @@ -15335,6 +15520,7 @@ "integrity": "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", @@ -15374,6 +15560,7 @@ "integrity": "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", @@ -15391,6 +15578,7 @@ "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", @@ -15409,6 +15597,7 @@ "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", @@ -15428,6 +15617,7 @@ "integrity": "sha512-JBZ+xcb6kkex8XnBd4VHw1ZxjV6kmcwUumSHaIFdka2qzMCo7Klcy4sI6G0XtUpG/vtpislQCc+S9Bc+NLHYMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1" @@ -15443,6 +15633,7 @@ "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/portal": "^1.0.2", @@ -15462,6 +15653,7 @@ "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", @@ -15481,6 +15673,7 @@ "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", @@ -15497,6 +15690,7 @@ "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/mini-decimal": "^1.0.1", @@ -15515,6 +15709,7 @@ "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.0.0", @@ -15534,6 +15729,7 @@ "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", @@ -15553,6 +15749,7 @@ "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", @@ -15575,6 +15772,7 @@ "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", @@ -15591,6 +15789,7 @@ "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", @@ -15608,6 +15807,7 @@ "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.7", "classnames": "^2.2.1", @@ -15625,6 +15825,7 @@ "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "react-is": "^18.2.0" @@ -15639,7 +15840,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/re-resizable": { "version": "6.11.2", @@ -15647,6 +15849,7 @@ "integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -15657,7 +15860,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15668,6 +15870,7 @@ "integrity": "sha512-Zto7u9l6Wd5LPPtjeFJ+7uwoT4bs01OSgkN2kxD18lWl8IiZ0GY3nWCbKPx4qIU7Au1vENsMJm19rfVWHHayaQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -15679,6 +15882,7 @@ "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" @@ -15689,7 +15893,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15703,6 +15906,7 @@ "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" @@ -15718,6 +15922,7 @@ "integrity": "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", @@ -15736,6 +15941,7 @@ "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } @@ -15745,7 +15951,8 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-hotkeys-hook": { "version": "5.2.4", @@ -15753,6 +15960,7 @@ "integrity": "sha512-BgKg+A1+TawkYluh5Bo4cTmcgMN5L29uhJbDUQdHwPX+qgXRjIPYU5kIDHyxnAwCkCBiu9V5OpB2mpyeluVF2A==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" @@ -15799,6 +16007,7 @@ "integrity": "sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/gregberge" @@ -15818,6 +16027,7 @@ "integrity": "sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "re-resizable": "^6.11.2", "react-draggable": "^4.5.0", @@ -15833,7 +16043,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", @@ -15862,6 +16073,7 @@ "integrity": "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8", "npm": ">=5" @@ -15907,6 +16119,7 @@ "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", @@ -15923,6 +16136,7 @@ "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", @@ -15944,6 +16158,7 @@ "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", @@ -15961,6 +16176,7 @@ "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", @@ -16018,6 +16234,7 @@ "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "regex-utilities": "^2.3.0" } @@ -16028,6 +16245,7 @@ "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "regex-utilities": "^2.3.0" } @@ -16037,7 +16255,8 @@ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -16066,6 +16285,7 @@ "integrity": "sha512-6di6kEu9WUHKLKrkKG2xX6AOuaCMGghg0Wq7MEuM/jBYUPVIq6PJpMe00dxMfU+/YSBtDXhffpDimgDi+BObIQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@primer/octicons": "^19.20.0", "hast-util-from-html": "^2.0.3", @@ -16118,6 +16338,7 @@ "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", @@ -16149,6 +16370,7 @@ "integrity": "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", @@ -16165,6 +16387,7 @@ "integrity": "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "micromark-extension-cjk-friendly": "2.0.1" }, @@ -16206,6 +16429,7 @@ "integrity": "sha512-ByefQKFN184LeiGRCabfl7zUJsdlMYWEhiLX1gpmQ11yFg6xSuOTW7LVCv0oc1x+YvUMJW23NU36sJX2RWGgvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0", @@ -16242,6 +16466,7 @@ "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" @@ -16307,21 +16532,24 @@ "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.0.tgz", "integrity": "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/resolve": { "version": "1.22.11", @@ -16529,6 +16757,7 @@ "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" }, @@ -16542,6 +16771,7 @@ "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "compute-scroll-into-view": "^3.0.2" } @@ -16561,7 +16791,8 @@ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/set-function-length": { "version": "1.2.2", @@ -16618,6 +16849,7 @@ "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", @@ -16634,6 +16866,7 @@ "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16732,6 +16965,7 @@ "integrity": "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", @@ -16752,6 +16986,7 @@ "integrity": "sha512-4pz6JGSDmVTTkPJ/ueixHkFAXY4ySCc+unvCaDZV7hqq/sdJZirRxgIXSuNSKgiFlGTgRR97sdu2R8K55sPsrw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/core": "^3.0.0" }, @@ -16781,6 +17016,7 @@ "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", @@ -16794,6 +17030,7 @@ "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" @@ -16907,6 +17144,7 @@ "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16920,6 +17158,7 @@ "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "extend-shallow": "^3.0.0" }, @@ -16933,6 +17172,7 @@ "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" @@ -16991,7 +17231,8 @@ "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/string.prototype.includes": { "version": "2.0.1", @@ -17226,6 +17467,7 @@ "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" @@ -17239,7 +17481,8 @@ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss": { "version": "4.2.2", @@ -17268,6 +17511,7 @@ "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.22" } @@ -17323,7 +17567,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17350,6 +17593,7 @@ "integrity": "sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "vfile": "^6.0.0" }, @@ -17415,6 +17659,7 @@ "integrity": "sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -17541,7 +17786,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17628,7 +17872,6 @@ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", @@ -17692,6 +17935,7 @@ "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/unist": "^3.0.0" }, @@ -17842,6 +18086,7 @@ "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } @@ -17862,6 +18107,7 @@ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -17878,7 +18124,8 @@ "resolved": "https://registry.npmjs.org/v8n/-/v8n-1.5.1.tgz", "integrity": "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vfile": { "version": "6.0.3", @@ -17931,6 +18178,7 @@ "integrity": "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", @@ -18096,7 +18344,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -18163,7 +18410,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18196,6 +18442,7 @@ "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.7.0" }, From acb2e48da97bb280980cd9482c0b486d1cf7b48d Mon Sep 17 00:00:00 2001 From: mengnanxyyyy <308822704+mengnanxyyyy@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:16:57 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E6=89=93=E5=BC=80=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E5=88=87=E5=9B=9E=E5=8F=B3=E4=BE=A7?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点击聊天中的文件链接时,若右侧面板正处于 HTML 预览视图,现在会 自动切换回文件视图并显示新打开的文件(此前停留在预览视图看不到)。 --- components/AppShell.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 0b5d8dfd2..649494871 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -528,6 +528,9 @@ export function AppShell() { }); }); setActiveFileTabId(tabId); + // A file open always surfaces the files view, even when the panel was + // showing html previews. + setRightPanelView("files"); setRightPanelOpen(true); // On mobile the file panel is full-screen; close the drawer so it shows. if (isMobile) setSidebarOpen(false); From 8c7bb5301ad166f5ee508aa29d852d735179ffb2 Mon Sep 17 00:00:00 2001 From: mengnanxyyyy <308822704+mengnanxyyyy@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:05:31 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20tab=20=E5=8F=B3=E9=94=AE=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E9=A2=84=E8=A7=88=20tab=20=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E9=98=B2=E5=BE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ContextMenu 通用组件:portal 渲染到 body(避免面板 transform 裁切错位), 点击外部/Esc/失焦关闭,贴边自动翻转,标准菜单样式 - TabBar(文件)与 HtmlPreviewPanel(预览)右键菜单:关闭/关闭其他/关闭左侧/ 关闭右侧/关闭全部,边界项自动禁用,关闭后激活被右键的 tab - AppShell 新增 6 个批量关闭回调(文件 3 + 预览 3),会话清理逻辑不动 - 预览 tab 身份防御:同名 <title> 自动加序号区分("My Page (2)"); 同身份但内容不同(entryId 竞态错位)时派生变体身份新开,不覆盖旧 tab; 点击时零计算(身份 key 渲染时拼好),无 hash 无遍历 - useAgentSession 保持原样,不改动原项目逻辑 --- app/globals.css | 35 ++++++++++++ components/AppShell.tsx | 95 ++++++++++++++++++++++++++++++--- components/ContextMenu.test.mjs | 42 +++++++++++++++ components/ContextMenu.tsx | 87 ++++++++++++++++++++++++++++++ components/HtmlPreviewPanel.tsx | 43 ++++++++++++++- components/TabBar.tsx | 43 ++++++++++++++- lib/i18n/messages/en.ts | 4 ++ lib/i18n/messages/zh-CN.ts | 4 ++ 8 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 components/ContextMenu.test.mjs create mode 100644 components/ContextMenu.tsx diff --git a/app/globals.css b/app/globals.css index e5eced2c9..8c8c80318 100644 --- a/app/globals.css +++ b/app/globals.css @@ -483,6 +483,41 @@ pre, code { background: color-mix(in srgb, var(--accent) 12%, transparent); } +/* Right-click context menu (tab bars) — rendered via portal into body */ +.context-menu { + position: fixed; + z-index: 400; + min-width: 160px; + padding: 4px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 6px 24px color-mix(in srgb, #000 22%, transparent); + font-size: 12px; +} +.context-menu button { + display: flex; + align-items: center; + width: 100%; + height: 26px; + padding: 0 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; + font: inherit; + white-space: nowrap; +} +.context-menu button:hover:not(:disabled) { + background: var(--bg-hover); +} +.context-menu button:disabled { + color: var(--text-dim); + cursor: default; +} + .mermaid-block { padding: 12px; overflow-x: auto; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 649494871..e075c3edb 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -555,16 +555,34 @@ export function AppShell() { // Open (or focus) an html preview tab from a chat code block. const handleOpenHtmlPreview = useCallback((request: HtmlPreviewRequest) => { - const tabId = `preview:${request.key}`; + // Tab identity = block identity (sessionId:entryId:blockIndex) — zero + // computation at click time. If a session-load race ever hands two + // different blocks the same entryId, the existing tab would carry stale + // content; detect that by content equality (cheap: same block reuses the + // identical string) and give the new content a derived variant identity + // instead of overwriting. + const baseId = `preview:${request.key}`; setHtmlPreviewTabs((prev) => { - const existing = prev.find((tab) => tab.id === tabId); + const existing = prev.find((tab) => tab.id === baseId); if (existing) { - if (existing.title === request.title && existing.html === request.html) return prev; - return prev.map((tab) => (tab.id === tabId ? { ...tab, title: request.title, html: request.html } : tab)); + if (existing.html === request.html && existing.title === request.title) return prev; + // Same identity, different content — likely a stale/racy entryId. + // Keep the old tab untouched and open the new content separately. + let n = 2; + let variantId = `${baseId}~${n}`; + while (prev.some((tab) => tab.id === variantId)) { + n += 1; + variantId = `${baseId}~${n}`; + } + return [...prev, { id: variantId, title: request.title, html: request.html }]; } - return [...prev, { id: tabId, title: request.title, html: request.html }]; + // Disambiguate tabs that share the same <title> ("My Page", "My Page (2)"). + const base = request.title; + const sameTitleCount = prev.filter((tab) => tab.title === base).length; + const displayTitle = sameTitleCount === 0 ? base : `${base} (${sameTitleCount + 1})`; + return [...prev, { id: baseId, title: displayTitle, html: request.html }]; }); - setActiveHtmlPreviewTabId(tabId); + setActiveHtmlPreviewTabId(baseId); setRightPanelView("preview"); setRightPanelOpen(true); // On mobile the file panel is full-screen; close the drawer so it shows. @@ -588,6 +606,63 @@ export function AppShell() { }); }, [htmlPreviewTabs]); + // Right-click tab menu: close others / close to the right / close all (files) + const handleCloseOtherFileTabs = useCallback((tabId: string) => { + setFileTabs((prev) => prev.filter((tab) => tab.id === tabId)); + setActiveFileTabId(tabId); + }, []); + + const handleCloseRightFileTabs = useCallback((tabId: string) => { + setFileTabs((prev) => { + const index = prev.findIndex((tab) => tab.id === tabId); + return index < 0 ? prev : prev.slice(0, index + 1); + }); + setActiveFileTabId(tabId); + }, []); + + const handleCloseLeftFileTabs = useCallback((tabId: string) => { + setFileTabs((prev) => { + const index = prev.findIndex((tab) => tab.id === tabId); + return index < 0 ? prev : prev.slice(index); + }); + setActiveFileTabId(tabId); + }, []); + + const handleCloseAllFileTabs = useCallback(() => { + setFileTabs([]); + setActiveFileTabId(null); + setRightPanelOpen(false); + }, []); + + // Right-click tab menu: close others / close to the right / close all (previews) + const handleCloseOtherHtmlPreviewTabs = useCallback((tabId: string) => { + setHtmlPreviewTabs((prev) => prev.filter((tab) => tab.id === tabId)); + setActiveHtmlPreviewTabId(tabId); + }, []); + + const handleCloseRightHtmlPreviewTabs = useCallback((tabId: string) => { + setHtmlPreviewTabs((prev) => { + const index = prev.findIndex((tab) => tab.id === tabId); + return index < 0 ? prev : prev.slice(0, index + 1); + }); + setActiveHtmlPreviewTabId(tabId); + }, []); + + const handleCloseLeftHtmlPreviewTabs = useCallback((tabId: string) => { + setHtmlPreviewTabs((prev) => { + const index = prev.findIndex((tab) => tab.id === tabId); + return index < 0 ? prev : prev.slice(index); + }); + setActiveHtmlPreviewTabId(tabId); + }, []); + + const handleCloseAllHtmlPreviewTabs = useCallback(() => { + setHtmlPreviewTabs([]); + setActiveHtmlPreviewTabId(null); + setRightPanelView("files"); + setRightPanelOpen(false); + }, []); + const handleViewFullHistory = useCallback(() => { if (!selectedSession) return; window.open( @@ -1642,6 +1717,10 @@ export function AppShell() { activeTabId={activeFileTabId ?? ""} onSelectTab={setActiveFileTabId} onCloseTab={handleCloseFileTab} + onCloseOthers={handleCloseOtherFileTabs} + onCloseLeft={handleCloseLeftFileTabs} + onCloseRight={handleCloseRightFileTabs} + onCloseAll={handleCloseAllFileTabs} /> </div> </div> @@ -1675,6 +1754,10 @@ export function AppShell() { activeTabId={activeHtmlPreviewTabId} onSelectTab={setActiveHtmlPreviewTabId} onCloseTab={handleCloseHtmlPreviewTab} + onCloseOthers={handleCloseOtherHtmlPreviewTabs} + onCloseLeft={handleCloseLeftHtmlPreviewTabs} + onCloseRight={handleCloseRightHtmlPreviewTabs} + onCloseAll={handleCloseAllHtmlPreviewTabs} view={rightPanelView} onViewChange={setRightPanelView} /> diff --git a/components/ContextMenu.test.mjs b/components/ContextMenu.test.mjs new file mode 100644 index 000000000..7267d642b --- /dev/null +++ b/components/ContextMenu.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + jsx: { runtime: "automatic" }, + tsconfigPaths: true, +}); +const { ContextMenu } = await jiti.import("./ContextMenu.tsx"); + +function renderMenu(items, x = 100, y = 100) { + return renderToStaticMarkup( + React.createElement(ContextMenu, { x, y, items, onClose: () => {} }), + ); +} + +test("renders all menu items", () => { + const items = [ + { label: "Close", onClick: () => {} }, + { label: "Close Others", onClick: () => {} }, + { label: "Close All", onClick: () => {} }, + ]; + const html = renderMenu(items); + + assert.match(html, />Close</); + assert.match(html, />Close Others</); + assert.match(html, />Close All</); + assert.match(html, /role="menu"/); + assert.match(html, /role="menuitem"/); +}); + +test("marks disabled items", () => { + const items = [ + { label: "Close", onClick: () => {} }, + { label: "Close Others", onClick: () => {}, disabled: true }, + ]; + const html = renderMenu(items); + + assert.match(html, /disabled=""/); +}); diff --git a/components/ContextMenu.tsx b/components/ContextMenu.tsx new file mode 100644 index 000000000..fd7e02da7 --- /dev/null +++ b/components/ContextMenu.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; + +export interface ContextMenuItem { + label: string; + onClick: () => void; + disabled?: boolean; +} + +interface ContextMenuProps { + x: number; + y: number; + items: ContextMenuItem[]; + onClose: () => void; +} + +const MENU_WIDTH = 160; +const MENU_ITEM_HEIGHT = 26; +const MENU_PADDING = 8; +const EDGE_MARGIN = 4; + +/** + * Standard right-click context menu. Rendered through a portal into document.body + * so ancestors with transform/overflow (e.g. the animating right panel) cannot + * clip or offset it. Closes on outside click, Escape or window blur; flips near + * viewport edges. Client-only (rendered on demand). + */ +export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { + const ref = useRef<HTMLDivElement>(null); + + useEffect(() => { + const onPointerDown = (event: PointerEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) onClose(); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + const onBlur = () => onClose(); + window.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("blur", onBlur); + }; + }, [onClose]); + + // SSR-safe viewport math (only rendered client-side, but tests use + // renderToStaticMarkup where window/document are absent). + const viewportW = typeof window === "undefined" ? 0 : window.innerWidth; + const viewportH = typeof window === "undefined" ? 0 : window.innerHeight; + const estimatedHeight = items.length * MENU_ITEM_HEIGHT + MENU_PADDING; + const left = Math.max(EDGE_MARGIN, Math.min(x, viewportW - MENU_WIDTH - EDGE_MARGIN)); + const top = Math.max(EDGE_MARGIN, Math.min(y, viewportH - estimatedHeight - EDGE_MARGIN)); + + const menu = ( + <div + ref={ref} + role="menu" + className="context-menu" + style={{ left, top }} + > + {items.map((item) => ( + <button + key={item.label} + type="button" + role="menuitem" + disabled={item.disabled} + onClick={() => { + item.onClick(); + onClose(); + }} + > + {item.label} + </button> + ))} + </div> + ); + + // Portal to body: ancestors with transform (panel animations) or overflow + // would otherwise clip/offset a fixed-position menu. + if (typeof document === "undefined") return menu; + return createPortal(menu, document.body); +} diff --git a/components/HtmlPreviewPanel.tsx b/components/HtmlPreviewPanel.tsx index e3e51cba5..c9e6b4874 100644 --- a/components/HtmlPreviewPanel.tsx +++ b/components/HtmlPreviewPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useI18n } from "@/hooks/useI18n"; import { RightPanelViewSwitcher } from "./RightPanelViewSwitcher"; +import { ContextMenu } from "./ContextMenu"; export interface HtmlPreviewTab { id: string; @@ -15,6 +16,10 @@ interface HtmlPreviewPanelProps { activeTabId: string | null; onSelectTab: (id: string) => void; onCloseTab: (id: string) => void; + onCloseOthers?: (id: string) => void; + onCloseLeft?: (id: string) => void; + onCloseRight?: (id: string) => void; + onCloseAll?: () => void; view: "files" | "preview"; onViewChange: (view: "files" | "preview") => void; } @@ -27,10 +32,16 @@ const PREVIEW_TAB_WIDTH = 150; * becomes one tab (title = document <title>, "untitled" fallback). Content runs * in a sandboxed iframe so it cannot touch the host page. */ -export function HtmlPreviewPanel({ tabs, activeTabId, onSelectTab, onCloseTab, view, onViewChange }: HtmlPreviewPanelProps) { +export function HtmlPreviewPanel({ tabs, activeTabId, onSelectTab, onCloseTab, onCloseOthers, onCloseLeft, onCloseRight, onCloseAll, view, onViewChange }: HtmlPreviewPanelProps) { const { t } = useI18n(); const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; const [loaded, setLoaded] = useState(false); + const [menu, setMenu] = useState<{ tabId: string; x: number; y: number } | null>(null); + + const closeOthers = onCloseOthers ?? (() => {}); + const closeLeft = onCloseLeft ?? (() => {}); + const closeRight = onCloseRight ?? (() => {}); + const closeAll = onCloseAll ?? (() => {}); // A new tab remounts the iframe — reset the loading flag. useEffect(() => { @@ -70,6 +81,10 @@ export function HtmlPreviewPanel({ tabs, activeTabId, onSelectTab, onCloseTab, v e.stopPropagation(); onCloseTab(tab.id); }} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ tabId: tab.id, x: e.clientX, y: e.clientY }); + }} style={{ display: "flex", alignItems: "center", @@ -123,6 +138,32 @@ export function HtmlPreviewPanel({ tabs, activeTabId, onSelectTab, onCloseTab, v </div> ); })} + {menu && ( + <ContextMenu + x={menu.x} + y={menu.y} + onClose={() => setMenu(null)} + items={[ + { label: t("i18n.close"), onClick: () => onCloseTab(menu.tabId) }, + { + label: t("i18n.closeOthers"), + onClick: () => closeOthers(menu.tabId), + disabled: tabs.length <= 1, + }, + { + label: t("i18n.closeLeft"), + onClick: () => closeLeft(menu.tabId), + disabled: tabs[0]?.id === menu.tabId, + }, + { + label: t("i18n.closeRight"), + onClick: () => closeRight(menu.tabId), + disabled: tabs[tabs.length - 1]?.id === menu.tabId, + }, + { label: t("i18n.closeAll"), onClick: closeAll }, + ]} + /> + )} </div> {activeTab ? ( diff --git a/components/TabBar.tsx b/components/TabBar.tsx index 32e8ac48b..dc0cb3324 100644 --- a/components/TabBar.tsx +++ b/components/TabBar.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { getFileIcon } from "./FileIcons"; import { useI18n } from "@/hooks/useI18n"; +import { ContextMenu } from "./ContextMenu"; export interface Tab { id: string; @@ -17,11 +18,21 @@ interface Props { activeTabId: string; onSelectTab: (id: string) => void; onCloseTab: (id: string) => void; + onCloseOthers?: (id: string) => void; + onCloseLeft?: (id: string) => void; + onCloseRight?: (id: string) => void; + onCloseAll?: () => void; } -export function TabBar({ tabs, activeTabId, onSelectTab, onCloseTab }: Props) { +export function TabBar({ tabs, activeTabId, onSelectTab, onCloseTab, onCloseOthers, onCloseLeft, onCloseRight, onCloseAll }: Props) { const { t } = useI18n(); const [hoveredClose, setHoveredClose] = useState<string | null>(null); + const [menu, setMenu] = useState<{ tabId: string; x: number; y: number } | null>(null); + + const closeOthers = onCloseOthers ?? (() => {}); + const closeLeft = onCloseLeft ?? (() => {}); + const closeRight = onCloseRight ?? (() => {}); + const closeAll = onCloseAll ?? (() => {}); return ( <div @@ -49,6 +60,10 @@ export function TabBar({ tabs, activeTabId, onSelectTab, onCloseTab }: Props) { e.stopPropagation(); onCloseTab(tab.id); }} + onContextMenu={(e) => { + e.preventDefault(); + setMenu({ tabId: tab.id, x: e.clientX, y: e.clientY }); + }} style={{ display: "flex", alignItems: "center", @@ -110,6 +125,32 @@ export function TabBar({ tabs, activeTabId, onSelectTab, onCloseTab }: Props) { </div> ); })} + {menu && ( + <ContextMenu + x={menu.x} + y={menu.y} + onClose={() => setMenu(null)} + items={[ + { label: t("i18n.close"), onClick: () => onCloseTab(menu.tabId) }, + { + label: t("i18n.closeOthers"), + onClick: () => closeOthers(menu.tabId), + disabled: tabs.length <= 1, + }, + { + label: t("i18n.closeLeft"), + onClick: () => closeLeft(menu.tabId), + disabled: tabs[0]?.id === menu.tabId, + }, + { + label: t("i18n.closeRight"), + onClick: () => closeRight(menu.tabId), + disabled: tabs[tabs.length - 1]?.id === menu.tabId, + }, + { label: t("i18n.closeAll"), onClick: closeAll }, + ]} + /> + )} </div> ); } diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 18d1b4dc1..b6e909703 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -320,6 +320,10 @@ export const enLocale: LocalePlugin = { "i18n.collapseCode": "Collapse", "i18n.loadingPreview": "Loading preview…", "i18n.emptyHtmlPreview": "No HTML previews open", + "i18n.closeOthers": "Close Others", + "i18n.closeLeft": "Close to the Left", + "i18n.closeRight": "Close to the Right", + "i18n.closeAll": "Close All", "i18n.invalidMermaid": "Invalid Mermaid diagram", "i18n.renderingMermaid": "Rendering Mermaid diagram", "i18n.previewAfterStreaming": "Preview available after streaming", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index b9c4a65f0..f989ebc74 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -320,6 +320,10 @@ export const zhCNLocale: LocalePlugin = { "i18n.collapseCode": "收起", "i18n.loadingPreview": "正在加载预览…", "i18n.emptyHtmlPreview": "没有打开的 HTML 预览", + "i18n.closeOthers": "关闭其他", + "i18n.closeLeft": "关闭左侧", + "i18n.closeRight": "关闭右侧", + "i18n.closeAll": "关闭全部", "i18n.invalidMermaid": "Mermaid 图表无效", "i18n.renderingMermaid": "正在渲染 Mermaid 图表", "i18n.previewAfterStreaming": "流式输出完成后可预览", From b3057ca223c5c9846d43a363c125c8dfa42195e8 Mon Sep 17 00:00:00 2001 From: mengnanxyyyy <308822704+mengnanxyyyy@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:30:29 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20=E6=89=80=E6=9C=89=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=9D=97=E9=99=90=E9=AB=98=20300px=20=E5=8F=AF?= =?UTF-8?q?=E5=B1=95=E5=BC=80,=E6=8A=98=E5=8F=A0=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E7=A7=BB=E8=87=B3=20header=20=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 折叠逻辑从 HtmlPreviewBlock 下沉到通用 CodeBlock:所有 ``` 代码块 (js/py/html/mermaid 源码视图等)统一默认限高 300px,超长显示展开/收起 - 折叠按钮移到代码块 header 栏(与预览/复制并排),仅超长时出现, 不再悬浮遮挡最后一行代码 - 修复折叠检测 bug:测量元素改为 maxHeight wrapper 自身(原检测外层 容器永远 false,折叠按钮实际从未显示),并加 rAF 二次检测防字体漏报 - HtmlPreviewBlock 大幅简化,只保留预览按钮与 <title> 解析 --- app/globals.css | 20 ------ components/HtmlPreviewBlock.tsx | 39 +---------- components/MermaidBlock.test.mjs | 29 ++++++++ components/MermaidBlock.tsx | 109 ++++++++++++++++++++----------- 4 files changed, 104 insertions(+), 93 deletions(-) diff --git a/app/globals.css b/app/globals.css index 8c8c80318..0f87afc5d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -439,26 +439,6 @@ pre, code { background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--bg) 88%, transparent)); pointer-events: none; } -.markdown-code-expand { - position: absolute; - left: 50%; - bottom: 8px; - transform: translateX(-50%); - border: 1px solid var(--border); - border-radius: 5px; - padding: 3px 12px; - background: var(--bg-panel); - color: var(--text-muted); - cursor: pointer; - font: inherit; - font-size: 11px; - line-height: 1.4; - box-shadow: 0 1px 3px color-mix(in srgb, #000 18%, transparent); -} -.markdown-code-expand:hover { - color: var(--text); - border-color: color-mix(in srgb, var(--border) 70%, var(--text-muted)); -} /* Right panel view switcher — icon buttons, active view uses the accent color */ .right-panel-view-switch { diff --git a/components/HtmlPreviewBlock.tsx b/components/HtmlPreviewBlock.tsx index ef4386d32..c3f4a7825 100644 --- a/components/HtmlPreviewBlock.tsx +++ b/components/HtmlPreviewBlock.tsx @@ -1,12 +1,8 @@ "use client"; -import { useLayoutEffect, useRef, useState } from "react"; import { useI18n } from "@/hooks/useI18n"; import { CodeBlock } from "./MermaidBlock"; -/** Collapsed height for html code blocks in the chat. */ -export const HTML_CODE_MAX_HEIGHT = 300; - /** * A preview request emitted by an html code block. `key` uniquely identifies * the block (sessionId:entryId:blockIndex) so the right panel can dedupe tabs. @@ -47,22 +43,11 @@ interface HtmlPreviewBlockProps { } /** - * HTML code fence with a capped height ("expand full" toggle) and a "preview" - * button that opens the content in the right-side preview panel. + * HTML code fence with a "preview" button that opens the content in the + * right-side preview panel. Height collapse/expand is inherited from CodeBlock. */ export function HtmlPreviewBlock({ code, isStreaming, previewKey, onOpenPreview }: HtmlPreviewBlockProps) { const { t } = useI18n(); - const [expanded, setExpanded] = useState(false); - const [overflowing, setOverflowing] = useState(false); - const bodyRef = useRef<HTMLDivElement>(null); - - // Detect whether the code exceeds the collapsed height. When expanded, keep - // the collapse affordance available no matter how the content changes. - useLayoutEffect(() => { - const el = bodyRef.current; - if (!el) return; - setOverflowing(expanded ? true : el.scrollHeight > el.clientHeight + 1); - }, [code, expanded]); const openPreview = () => { if (!previewKey || !onOpenPreview || isStreaming) return; @@ -85,23 +70,5 @@ export function HtmlPreviewBlock({ code, isStreaming, previewKey, onOpenPreview </button> ); - return ( - <div className="markdown-code-collapse"> - <div ref={bodyRef} className="markdown-code-collapse-body"> - <CodeBlock code={code} lang="html" maxHeight={expanded ? undefined : HTML_CODE_MAX_HEIGHT} headerAction={previewButton} /> - </div> - {overflowing && ( - <> - {!expanded && <div className="markdown-code-fade" aria-hidden="true" />} - <button - type="button" - className="markdown-code-expand" - onClick={() => setExpanded((v) => !v)} - > - {expanded ? t("i18n.collapseCode") : t("i18n.expandCode")} - </button> - </> - )} - </div> - ); + return <CodeBlock code={code} lang="html" headerAction={previewButton} />; } diff --git a/components/MermaidBlock.test.mjs b/components/MermaidBlock.test.mjs index c0c00fb97..fb406b6f6 100644 --- a/components/MermaidBlock.test.mjs +++ b/components/MermaidBlock.test.mjs @@ -9,6 +9,7 @@ const jiti = createJiti(import.meta.url, { tsconfigPaths: true, }); const { MermaidBlock } = await jiti.import("./MermaidBlock.tsx"); +const { CodeBlock } = await jiti.import("./MermaidBlock.tsx"); const { I18nProvider } = await jiti.import("../hooks/useI18n.tsx"); // Simple sequenceDiagram for testing @@ -68,3 +69,31 @@ test("MermaidBlock handles Chinese characters in diagram", () => { assert.doesNotMatch(html, /mermaid-block-error/); assert.match(html, /mermaid-block/); }); + +function renderCodeBlock(props) { + return renderToStaticMarkup( + React.createElement( + I18nProvider, + null, + React.createElement(CodeBlock, props), + ), + ); +} + +test("CodeBlock caps height at 300px by default", () => { + const html = renderCodeBlock({ code: "const x = 1;".repeat(200) }); + + assert.match(html, /max-height:300px/); + assert.match(html, /overflow:hidden/); + // The expand toggle is measured client-side only (useLayoutEffect), so SSR + // output stays clean. + assert.doesNotMatch(html, /Expand/); + assert.doesNotMatch(html, /markdown-code-expand/); +}); + +test("CodeBlock renders header actions and copy button", () => { + const html = renderCodeBlock({ code: "const x = 1;", lang: "js" }); + + assert.match(html, />js</); + assert.match(html, />Copy</); +}); diff --git a/components/MermaidBlock.tsx b/components/MermaidBlock.tsx index a4934d5de..5e46c7009 100644 --- a/components/MermaidBlock.tsx +++ b/components/MermaidBlock.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; @@ -134,18 +134,39 @@ interface CodeBlockProps { code: string; lang: string; headerAction?: ReactNode; - /** Optional cap on the code body height (scrollable). */ - maxHeight?: number; } +/** Collapsed height for chat code fences (all languages). */ +export const CODE_MAX_HEIGHT = 300; + /** - * Syntax-highlighted code block with copy button. - * Used as the "source" view for mermaid blocks and for all non-mermaid code fences. + * Syntax-highlighted code block with copy button and a capped-height + * expand/collapse toggle ("expand full" over a gradient fade). Used for all + * chat code fences — plain blocks, mermaid source view and html preview blocks. */ -export function CodeBlock({ code, lang, headerAction, maxHeight }: CodeBlockProps) { +export function CodeBlock({ code, lang, headerAction }: CodeBlockProps) { const { isDark } = useTheme(); const { t } = useI18n(); const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(false); + const [overflowing, setOverflowing] = useState(false); + // The measured element must be the maxHeight wrapper itself: its own + // scrollHeight is the full code height, while an outer element's scrollHeight + // only reflects the already-clipped descendant box (always false negative). + const bodyRef = useRef<HTMLDivElement>(null); + + // Detect whether the code exceeds the collapsed height. When expanded, keep + // the collapse affordance available no matter how the content changes. + useLayoutEffect(() => { + const el = bodyRef.current; + if (!el) return; + const measure = () => setOverflowing(expanded ? true : el.scrollHeight > el.clientHeight + 1); + measure(); + // Re-measure once the frame settles so late-loading fonts can't cause a + // false "fits" reading right after mount. + const frame = requestAnimationFrame(measure); + return () => cancelAnimationFrame(frame); + }, [code, expanded]); const copy = () => { copyText(code).then(() => { @@ -155,40 +176,54 @@ export function CodeBlock({ code, lang, headerAction, maxHeight }: CodeBlockProp }; return ( - <div className="markdown-code-block"> - <div className="markdown-code-header"> - <span className="markdown-code-lang">{lang || "text"}</span> - <div className="markdown-code-actions"> - {headerAction} - <button - onClick={copy} - className="markdown-code-action" + <div className="markdown-code-collapse"> + <div className="markdown-code-collapse-body"> + <div className="markdown-code-block"> + <div className="markdown-code-header"> + <span className="markdown-code-lang">{lang || "text"}</span> + <div className="markdown-code-actions"> + {headerAction} + {overflowing && ( + <button + type="button" + onClick={() => setExpanded((v) => !v)} + className="markdown-code-action" + > + {expanded ? t("i18n.collapseCode") : t("i18n.expandCode")} + </button> + )} + <button + onClick={copy} + className="markdown-code-action" + > + {copied ? t("i18n.copied") : t("i18n.copy")} + </button> + </div> + </div> + <div ref={bodyRef} style={expanded ? undefined : { maxHeight: CODE_MAX_HEIGHT, overflow: "hidden" }}> + <SyntaxHighlighter + language={lang || "text"} + style={isDark ? vscDarkPlus : vs} + showLineNumbers + lineNumberStyle={{ color: "var(--text-dim)", fontStyle: "normal" }} + customStyle={{ + margin: 0, + padding: "11px 13px", + fontSize: 12.5, + lineHeight: 1.62, + borderRadius: 0, + // Longhand only: the prism theme already sets backgroundColor, and + // mixing shorthand `background` with it triggers React style warnings. + backgroundColor: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))", + }} + codeTagProps={{ style: { fontFamily: "var(--font-mono)" } }} > - {copied ? t("i18n.copied") : t("i18n.copy")} - </button> + {code} + </SyntaxHighlighter> + </div> </div> </div> - <div style={maxHeight ? { maxHeight, overflow: "hidden" } : undefined}> - <SyntaxHighlighter - language={lang || "text"} - style={isDark ? vscDarkPlus : vs} - showLineNumbers - lineNumberStyle={{ color: "var(--text-dim)", fontStyle: "normal" }} - customStyle={{ - margin: 0, - padding: "11px 13px", - fontSize: 12.5, - lineHeight: 1.62, - borderRadius: 0, - // Longhand only: the prism theme already sets backgroundColor, and - // mixing shorthand `background` with it triggers React style warnings. - backgroundColor: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))", - }} - codeTagProps={{ style: { fontFamily: "var(--font-mono)" } }} - > - {code} - </SyntaxHighlighter> - </div> + {overflowing && !expanded && <div className="markdown-code-fade" aria-hidden="true" />} </div> ); } From a2104628c318c337b44c1776dd926139166e956f Mon Sep 17 00:00:00 2001 From: mengnanxyyyy <308822704+mengnanxyyyy@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:41:55 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20Mermaid=20=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=E5=99=A8=E5=AF=B9=E5=90=AB=20<br>=20=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E7=9A=84=E5=9B=BE=E8=A1=A8=E9=AB=98=E5=BA=A6=E9=94=99=E4=B9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根因: mermaid 将多行节点标签输出为裸 <br>(HTML 语法, XML 非法), DOMParser 的 image/svg+xml 严格解析失败, 回退 800×600 死尺寸, 导致查看器高度虚高、图表下方大片空白 - 修复三层防护: 1. 预处理将 <br> 归一化为 <br/>, 使 SVG 成为严格合法 XML 2. DOMParser 改用 text/html 模式解析(容忍 HTML 风格内容) 3. 新增正则兜底 prepareSvgFallback: 无 DOMParser 或解析失败时 从 viewBox/width/height 提取真实尺寸, 并清理固定尺寸约束 - 新增 6 个测试覆盖: 正则兜底尺寸提取、<br> 归一化、HTML 解析路径、 解析失败回退 --- components/ZoomPanViewer.test.mjs | 123 ++++++++++++++++++++++++++++++ components/ZoomPanViewer.tsx | 65 ++++++++++++++-- 2 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 components/ZoomPanViewer.test.mjs diff --git a/components/ZoomPanViewer.test.mjs b/components/ZoomPanViewer.test.mjs new file mode 100644 index 000000000..b030b429d --- /dev/null +++ b/components/ZoomPanViewer.test.mjs @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + jsx: { runtime: "automatic" }, + tsconfigPaths: true, +}); +const { prepareSvgForZoomPan } = await jiti.import("./ZoomPanViewer.tsx"); + +// Realistic mermaid v11 output: viewBox + width="100%" + max-width style, +// with <br> inside a foreignObject label (XML-invalid, HTML-valid). +const MERMAID_SVG = `<svg id="mermaid-x" width="100%" xmlns="http://www.w3.org/2000/svg" class="flowchart" style="max-width: 1027.65625px;" viewBox="0 0 1027.65625 350" role="graphics-document document"><style>.node rect{fill:#1f2020;}</style><g class="root"><g class="node"><g class="label"><foreignObject width="82"><div xmlns="http://www.w3.org/1999/xhtml"><span class="nodeLabel"><p>需求收集<br>访谈 + 问卷</p></span></div></foreignObject></g></g></g></svg>`; + +test("regex fallback extracts viewBox size and strips fixed-size constraints", () => { + const r = prepareSvgForZoomPan(MERMAID_SVG); + + assert.equal(r.width, 1027.65625); + assert.equal(r.height, 350); + assert.doesNotMatch(r.html, /max-width/); + assert.doesNotMatch(r.html, /width="100%"/); + assert.match(r.html, /viewBox="0 0 1027\.65625 350"/); +}); + +test("bare <br> is normalized to <br/> so the SVG is well-formed XML", () => { + const r = prepareSvgForZoomPan(MERMAID_SVG); + + assert.doesNotMatch(r.html, /<br(?![\s/])/i); + assert.match(r.html, /需求收集<br\/>访谈/); +}); + +test("regex fallback reads width/height attributes when viewBox is missing", () => { + const svg = `<svg width="640" height="480" xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`; + const r = prepareSvgForZoomPan(svg); + + assert.equal(r.width, 640); + assert.equal(r.height, 480); +}); + +test("regex fallback returns 800x600 when no size info exists", () => { + const r = prepareSvgForZoomPan("<svg></svg>"); + + assert.equal(r.width, 800); + assert.equal(r.height, 600); +}); + +test("DOMParser path parses with text/html (tolerates <br>) and cleans the svg", () => { + class FakeEl { + constructor(attrs) { + this.attrs = { ...attrs }; + this.style = { maxWidth: attrs._styleMaxWidth ?? "" }; + delete this.attrs._styleMaxWidth; + } + getAttribute(name) { + return name in this.attrs ? this.attrs[name] : null; + } + setAttribute(name, value) { + this.attrs[name] = value; + } + removeAttribute(name) { + delete this.attrs[name]; + } + get outerHTML() { + const attrs = Object.entries(this.attrs) + .map(([k, v]) => ` ${k}="${v}"`) + .join(""); + const style = this.style.maxWidth ? ` style="max-width:${this.style.maxWidth}"` : ""; + return `<svg${attrs}${style}></svg>`; + } + } + class FakeDOMParser { + static lastMime = null; + parseFromString(str, mime) { + FakeDOMParser.lastMime = mime; + const match = /<svg\s+([^>]*)>/.exec(str); + if (!match) return { querySelector: () => null }; + const attrs = {}; + for (const attr of match[1].matchAll(/([\w-]+)="([^"]*)"/g)) { + attrs[attr[1]] = attr[2]; + } + const styleAttr = attrs.style ?? ""; + delete attrs.style; + const maxWidth = /max-width:\s*([^;]+)/.exec(styleAttr); + if (maxWidth) attrs._styleMaxWidth = maxWidth[1]; + return { querySelector: () => new FakeEl(attrs) }; + } + } + + const prev = globalThis.DOMParser; + globalThis.DOMParser = FakeDOMParser; + try { + const r = prepareSvgForZoomPan(MERMAID_SVG); + + // The whole point: HTML parsing mode, not strict XML. + assert.equal(FakeDOMParser.lastMime, "text/html"); + assert.equal(r.width, 1027.65625); + assert.equal(r.height, 350); + assert.doesNotMatch(r.html, /width="100%"/); + // The original max-width cap is gone ("none" is the browser-serialized form). + assert.doesNotMatch(r.html, /max-width:\s*1027/); + assert.match(r.html, /viewBox="0 0 1027\.65625 350"/); + } finally { + globalThis.DOMParser = prev; + } +}); + +test("DOMParser path falls back to regex when no svg element is found", () => { + class EmptyDOMParser { + parseFromString() { + return { querySelector: () => null }; + } + } + + const prev = globalThis.DOMParser; + globalThis.DOMParser = EmptyDOMParser; + try { + const r = prepareSvgForZoomPan(MERMAID_SVG); + assert.equal(r.width, 1027.65625); + assert.equal(r.height, 350); + } finally { + globalThis.DOMParser = prev; + } +}); diff --git a/components/ZoomPanViewer.tsx b/components/ZoomPanViewer.tsx index 48c9c6471..e09726693 100644 --- a/components/ZoomPanViewer.tsx +++ b/components/ZoomPanViewer.tsx @@ -451,14 +451,24 @@ export function prepareSvgForZoomPan(svg: string): { width: number; height: number; } { + // Mermaid renders multi-line node labels as <br> (HTML syntax) inside + // foreignObject, which is invalid XML. Normalize to <br/> up front so the + // SVG is well-formed no matter which parser path runs. + const normalizedSvg = svg.replace(/<br>/gi, "<br/>"); + if (typeof DOMParser === "undefined") { - return { html: svg, width: 800, height: 600 }; + return prepareSvgFallback(normalizedSvg); } - const doc = new DOMParser().parseFromString(svg, "image/svg+xml"); - const el = doc.documentElement; - if (!el || el.tagName.toLowerCase() !== "svg") { - return { html: svg, width: 800, height: 600 }; + // Parse as HTML, not strict XML: mermaid emits <br> (never <br/>) inside + // foreignObject labels, which makes XML parsing fail and used to drop us into + // the wrong-size 800×600 fallback (viewer showed the chart with a huge empty + // band below it). The HTML parser tolerates <br> and still builds proper + // SVG-namespaced elements. + const doc = new DOMParser().parseFromString(normalizedSvg, "text/html"); + const el = doc.querySelector("svg"); + if (!el) { + return prepareSvgFallback(normalizedSvg); } let width = 0; @@ -482,8 +492,7 @@ export function prepareSvgForZoomPan(svg: string): { } if (!(width > 0 && height > 0)) { - width = 800; - height = 600; + return prepareSvgFallback(normalizedSvg); } // Resolution-independent: drop the fixed pixel size and stretch constraints. @@ -499,3 +508,45 @@ export function prepareSvgForZoomPan(svg: string): { return { html: el.outerHTML, width, height }; } + +/** + * Regex-based fallback for environments without DOMParser (SSR, tests) or when + * the SVG cannot be parsed at all. Extracts the real size from viewBox/width/ + * height and strips fixed-size constraints so the SVG scales with the viewer. + */ +function prepareSvgFallback(svg: string): { + html: string; + width: number; + height: number; +} { + let width = 0; + let height = 0; + + const viewBox = /viewBox=["']\s*([\d.eE+-]+)[\s,]+([\d.eE+-]+)[\s,]+([\d.eE+-]+)[\s,]+([\d.eE+-]+)\s*["']/i.exec(svg); + if (viewBox) { + width = Number(viewBox[3]); + height = Number(viewBox[4]); + } + if (!(width > 0 && height > 0)) { + const w = /<svg[^>]*\swidth=["']([\d.]+)["']/i.exec(svg); + const h = /<svg[^>]*\sheight=["']([\d.]+)["']/i.exec(svg); + if (w && h) { + width = Number(w[1]); + height = Number(h[1]); + } + } + if (!(width > 0 && height > 0)) { + return { html: svg, width: 800, height: 600 }; + } + + // Strip fixed width/height attributes and max-width so the SVG fills the + // viewer canvas and keeps its aspect ratio from viewBox alone. + const html = svg + .replace(/\swidth="[^"]*"/i, "") + .replace(/\sheight="[^"]*"/i, "") + .replace(/style="([^"]*)"/i, (match, style: string) => { + const rest = style.replace(/max-width:[^;]*;?/gi, "").trim(); + return rest ? ` style="${rest}"` : ""; + }); + return { html, width, height }; +}