feat: use react-resizable-panels for resizable section dividers

This commit is contained in:
Immac
2026-03-01 22:49:39 -06:00
parent 680af3d60a
commit dcfcb057eb
6 changed files with 123 additions and 168 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,4 +1,4 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["immac_style_mixer/StyleMixerPanel-B4CBSKrc.js","immac_style_mixer/vendor-DPfS0z4g.js"])))=>i.map(i=>d[i]);
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["immac_style_mixer/StyleMixerPanel-YndEdIPa.js","immac_style_mixer/vendor-DPfS0z4g.js"])))=>i.map(i=>d[i]);
import{r as h,a as R,R as v}from"./vendor-DPfS0z4g.js";const g="modulepreload",I=function(r){return"/"+r},f={},P=function(e,o,n){let c=Promise.resolve();if(o&&o.length>0){document.getElementsByTagName("link");const t=document.querySelector("meta[property=csp-nonce]"),i=(t==null?void 0:t.nonce)||(t==null?void 0:t.getAttribute("nonce"));c=Promise.allSettled(o.map(a=>{if(a=I(a),a in f)return;f[a]=!0;const d=a.endsWith(".css"),w=d?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${a}"]${w}`))return;const s=document.createElement("link");if(s.rel=d?"stylesheet":g,d||(s.as="script"),s.crossOrigin="",s.href=a,i&&s.setAttribute("nonce",i),document.head.appendChild(s),d)return new Promise((E,S)=>{s.addEventListener("load",E),s.addEventListener("error",()=>S(new Error(`Unable to preload CSS for ${a}`)))})}))}function l(t){const i=new Event("vite:preloadError",{cancelable:!0});if(i.payload=t,window.dispatchEvent(i),!i.defaultPrevented)throw t}return c.then(t=>{for(const i of t||[])i.status==="rejected"&&l(i.reason);return e().catch(l)})};var x={exports:{}},u={};/**
* @license React
* react-jsx-runtime.production.min.js
@@ -7,4 +7,4 @@ import{r as h,a as R,R as v}from"./vendor-DPfS0z4g.js";const g="modulepreload",I
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/var b=h,j=Symbol.for("react.element"),k=Symbol.for("react.fragment"),O=Object.prototype.hasOwnProperty,L=b.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,C={key:!0,ref:!0,__self:!0,__source:!0};function _(r,e,o){var n,c={},l=null,t=null;o!==void 0&&(l=""+o),e.key!==void 0&&(l=""+e.key),e.ref!==void 0&&(t=e.ref);for(n in e)O.call(e,n)&&!C.hasOwnProperty(n)&&(c[n]=e[n]);if(r&&r.defaultProps)for(n in e=r.defaultProps,e)c[n]===void 0&&(c[n]=e[n]);return{$$typeof:j,type:r,key:l,ref:t,props:c,_owner:L.current}}u.Fragment=k;u.jsx=_;u.jsxs=_;x.exports=u;var m=x.exports,p={},y=R;p.createRoot=y.createRoot,p.hydrateRoot=y.hydrateRoot;const T=v.lazy(()=>P(()=>import("./StyleMixerPanel-B4CBSKrc.js"),__vite__mapDeps([0,1])));function U(){return new Promise(r=>{function e(){if(window.app){r();return}const o=setInterval(()=>{window.app&&(clearInterval(o),r())},50);setTimeout(()=>{clearInterval(o),console.error("[ImmacStyleMixer] Timeout waiting for ComfyUI app"),r()},5e3)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e):e()})}async function M(){if(await U(),!window.app){console.error("[ImmacStyleMixer] ComfyUI app not available");return}window.app.extensionManager.registerSidebarTab({id:"immac-style-mixer",icon:"pi pi-palette",title:"Style Mixer",tooltip:"Immac Style Mixer",type:"custom",render:r=>{const e=document.createElement("div");e.id="immac-style-mixer-root",e.style.height="100%",r.appendChild(e),p.createRoot(e).render(m.jsx(v.StrictMode,{children:m.jsx(h.Suspense,{fallback:m.jsx("div",{style:{padding:"1rem"},children:"Loading…"}),children:m.jsx(T,{})})}))}}),window.app.registerExtension({name:"ImmacStyleMixer"})}M().catch(console.error);export{m as j};
*/var b=h,j=Symbol.for("react.element"),k=Symbol.for("react.fragment"),O=Object.prototype.hasOwnProperty,L=b.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,C={key:!0,ref:!0,__self:!0,__source:!0};function _(r,e,o){var n,c={},l=null,t=null;o!==void 0&&(l=""+o),e.key!==void 0&&(l=""+e.key),e.ref!==void 0&&(t=e.ref);for(n in e)O.call(e,n)&&!C.hasOwnProperty(n)&&(c[n]=e[n]);if(r&&r.defaultProps)for(n in e=r.defaultProps,e)c[n]===void 0&&(c[n]=e[n]);return{$$typeof:j,type:r,key:l,ref:t,props:c,_owner:L.current}}u.Fragment=k;u.jsx=_;u.jsxs=_;x.exports=u;var m=x.exports,p={},y=R;p.createRoot=y.createRoot,p.hydrateRoot=y.hydrateRoot;const T=v.lazy(()=>P(()=>import("./StyleMixerPanel-YndEdIPa.js"),__vite__mapDeps([0,1])));function U(){return new Promise(r=>{function e(){if(window.app){r();return}const o=setInterval(()=>{window.app&&(clearInterval(o),r())},50);setTimeout(()=>{clearInterval(o),console.error("[ImmacStyleMixer] Timeout waiting for ComfyUI app"),r()},5e3)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e):e()})}async function M(){if(await U(),!window.app){console.error("[ImmacStyleMixer] ComfyUI app not available");return}window.app.extensionManager.registerSidebarTab({id:"immac-style-mixer",icon:"pi pi-palette",title:"Style Mixer",tooltip:"Immac Style Mixer",type:"custom",render:r=>{const e=document.createElement("div");e.id="immac-style-mixer-root",e.style.height="100%",r.appendChild(e),p.createRoot(e).render(m.jsx(v.StrictMode,{children:m.jsx(h.Suspense,{fallback:m.jsx("div",{style:{padding:"1rem"},children:"Loading…"}),children:m.jsx(T,{})})}))}}),window.app.registerExtension({name:"ImmacStyleMixer"})}M().catch(console.error);export{m as j};
+12 -1
View File
@@ -9,7 +9,8 @@
"version": "0.1.0",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-resizable-panels": "^4.7.0"
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.20.2",
@@ -1713,6 +1714,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-resizable-panels": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.7.0.tgz",
"integrity": "sha512-QJTeh8KJR6lKzDwVez+WGPPUPuarIjR3ptg23thJ0G5dAMYJH4iSN1AnNY0DuSP+PgH8s9eYfwrR7AlmX6TvYA==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+2 -1
View File
@@ -12,7 +12,8 @@
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-resizable-panels": "^4.7.0"
},
"devDependencies": {
"@comfyorg/comfyui-frontend-types": "^1.20.2",
+106 -163
View File
@@ -1,4 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { Group as PanelGroup, Panel, Separator as PanelResizeHandle } from 'react-resizable-panels'
import { useStyleMixerData } from '../hooks/useStyleMixerData'
import { Mix, MixEntry, Style, StyleMixerData } from '../types'
import MixCard from './MixCard'
@@ -28,15 +29,10 @@ function SectionHeader({ title }: { title: string }) {
)
}
const DIVIDER_PX = 6
const MIN_FLEX = 0.08 // minimum section size as a fraction of total flex
export default function StyleMixerPanel() {
const { data, loading, error, update } = useStyleMixerData()
const [mixFilter, setMixFilter] = useState('')
// flex-grow proportions for the three panes; sum can be anything
const [sizes, setSizes] = useState([1, 2, 2])
const containerRef = useRef<HTMLDivElement>(null)
const currentMix = data.mixes.find((m) => m.id === data.current_mix_id) ?? null
@@ -91,39 +87,6 @@ export default function StyleMixerPanel() {
update((prev) => ({ ...prev, current_mix_id: id }))
}, [update])
// ── Drag-to-resize dividers ──────────────────────────────────────────────────
function startDrag(dividerIndex: number, startY: number) {
const container = containerRef.current
if (!container) return
const totalH = container.clientHeight - DIVIDER_PX * 2
const snapshot = [...sizes]
const snapshotTotal = snapshot.reduce((a, b) => a + b, 0)
function onMove(e: MouseEvent) {
const dy = e.clientY - startY
const delta = (dy / totalH) * snapshotTotal
setSizes((prev) => {
const next = [...prev]
next[dividerIndex] = Math.max(MIN_FLEX * snapshotTotal, snapshot[dividerIndex] + delta)
next[dividerIndex + 1] = Math.max(MIN_FLEX * snapshotTotal, snapshot[dividerIndex + 1] - delta)
return next
})
}
function onUp() {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.body.style.cursor = 'ns-resize'
document.body.style.userSelect = 'none'
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
if (loading) return <div style={panelShell}>Loading…</div>
if (error) return <div style={{ ...panelShell, color: '#e55' }}>Error: {error}</div>
@@ -135,159 +98,139 @@ export default function StyleMixerPanel() {
: sortedMixes
return (
<div ref={containerRef} style={panelShell}>
<PanelGroup orientation="vertical" style={panelShell}>
{/* ── Pane 0: Current Mix ──────────────────────────────────────────── */}
<div style={{ flex: sizes[0], minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={paneInner}>
<SectionHeader title="Current Mix" />
{!currentMix ? (
<div style={{ fontSize: 12, color: '#666' }}>No mix active — select one from Mixes below.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{currentMix.name}</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{currentMix.styles.length === 0 && (
<div style={{ fontSize: 12, color: '#666' }}>No styles in this mix yet.</div>
)}
{currentMix.styles.map((entry) => {
const style = data.styles.find((s) => s.id === entry.style_id)
return (
<div
key={entry.style_id}
style={{
border: `1px solid ${entry.enabled ? 'var(--p-primary-color, #6c6)' : 'var(--p-surface-border, #444)'}`,
borderRadius: 6,
padding: '6px 10px',
background: 'var(--p-surface-section, #1e1e1e)',
opacity: entry.enabled ? 1 : 0.45,
fontSize: 12,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
minWidth: 80,
}}
>
<span style={{ fontWeight: 600 }}>{style?.name ?? '?'}</span>
<span style={{ color: '#aaa' }}>×{entry.weight.toFixed(2)}</span>
</div>
)
})}
</div>
{/* ── Pane 0: Current Mix ─────────────────────────────────────────── */}
<Panel defaultSize={20} minSize={8} style={panelInner}>
<SectionHeader title="Current Mix" />
{!currentMix ? (
<div style={{ fontSize: 12, color: '#666' }}>No mix active — select one from Mixes below.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>{currentMix.name}</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{currentMix.styles.length === 0 && (
<div style={{ fontSize: 12, color: '#666' }}>No styles in this mix yet.</div>
)}
{currentMix.styles.map((entry) => {
const style = data.styles.find((s) => s.id === entry.style_id)
return (
<div
key={entry.style_id}
style={{
border: `1px solid ${entry.enabled ? 'var(--p-primary-color, #6c6)' : 'var(--p-surface-border, #444)'}`,
borderRadius: 6,
padding: '6px 10px',
background: 'var(--p-surface-section, #1e1e1e)',
opacity: entry.enabled ? 1 : 0.45,
fontSize: 12,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 4,
minWidth: 80,
}}
>
<span style={{ fontWeight: 600 }}>{style?.name ?? '?'}</span>
<span style={{ color: '#aaa' }}>×{entry.weight.toFixed(2)}</span>
</div>
)
})}
</div>
)}
</div>
</div>
{/* ── Divider 0 ────────────────────────────────────────────────────── */}
<div
onMouseDown={(e) => startDrag(0, e.clientY)}
style={dividerStyle}
title="Drag to resize"
>
<div style={dividerHandle} />
</div>
{/* ── Pane 1: Mixes ────────────────────────────────────────────────── */}
<div style={{ flex: sizes[1], minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={paneInner}>
<SectionHeader title="Mixes" />
<input
placeholder="Filter mixes…"
value={mixFilter}
onChange={(e) => setMixFilter(e.target.value)}
style={{ ...filterInput, marginBottom: 8 }}
/>
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', flexWrap: 'nowrap', paddingBottom: 6, flex: 1 }}>
{filteredMixes.map((mix) => (
<MixCard
key={mix.id}
mix={mix}
styles={data.styles}
isActive={mix.id === data.current_mix_id}
onActivate={() => setCurrentMix(mix.id)}
onUpdate={updateMix}
onDelete={() => deleteMix(mix.id)}
/>
))}
<button
title="Add a new mix"
onClick={addMix}
style={{
minWidth: 80,
border: '1px dashed var(--p-surface-border, #555)',
borderRadius: 8,
background: 'transparent',
color: '#666',
cursor: 'pointer',
fontSize: 22,
alignSelf: 'flex-start',
flexShrink: 0,
aspectRatio: '1',
}}
>
+
</button>
</div>
)}
</Panel>
<PanelResizeHandle style={resizeHandle}>
<div style={resizeHandlePill} />
</PanelResizeHandle>
{/* ── Pane 1: Mixes ───────────────────────────────────────────────── */}
<Panel defaultSize={40} minSize={8} style={panelInner}>
<SectionHeader title="Mixes" />
<input
placeholder="Filter mixes…"
value={mixFilter}
onChange={(e) => setMixFilter(e.target.value)}
style={{ ...filterInput, marginBottom: 8 }}
/>
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', flexWrap: 'nowrap', paddingBottom: 4, flex: 1 }}>
{filteredMixes.map((mix) => (
<MixCard
key={mix.id}
mix={mix}
styles={data.styles}
isActive={mix.id === data.current_mix_id}
onActivate={() => setCurrentMix(mix.id)}
onUpdate={updateMix}
onDelete={() => deleteMix(mix.id)}
/>
))}
<button
title="Add a new mix"
onClick={addMix}
style={{
minWidth: 80,
border: '1px dashed var(--p-surface-border, #555)',
borderRadius: 8,
background: 'transparent',
color: '#666',
cursor: 'pointer',
fontSize: 22,
alignSelf: 'flex-start',
flexShrink: 0,
aspectRatio: '1',
}}
>
+
</button>
</div>
</div>
</Panel>
{/* ── Divider 1 ────────────────────────────────────────────────────── */}
<div
onMouseDown={(e) => startDrag(1, e.clientY)}
style={dividerStyle}
title="Drag to resize"
>
<div style={dividerHandle} />
</div>
<PanelResizeHandle style={resizeHandle}>
<div style={resizeHandlePill} />
</PanelResizeHandle>
{/* ── Pane 2: Styles ───────────────────────────────────────────────── */}
<div style={{ flex: sizes[2], minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={paneInner}>
<SectionHeader title="Styles" />
<StyleGallery
styles={data.styles}
onUpdate={updateStyle}
onDelete={deleteStyle}
onAdd={addStyle}
/>
</div>
</div>
{/* ── Pane 2: Styles ──────────────────────────────────────────────── */}
<Panel defaultSize={40} minSize={8} style={panelInner}>
<SectionHeader title="Styles" />
<StyleGallery
styles={data.styles}
onUpdate={updateStyle}
onDelete={deleteStyle}
onAdd={addStyle}
/>
</Panel>
</div>
</PanelGroup>
)
}
const panelShell: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
color: 'var(--p-text-color, #eee)',
fontSize: 13,
}
const paneInner: React.CSSProperties = {
const panelInner: React.CSSProperties = {
padding: '10px 12px',
height: '100%',
overflowY: 'auto',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
}
const dividerStyle: React.CSSProperties = {
flexShrink: 0,
height: DIVIDER_PX,
cursor: 'ns-resize',
const resizeHandle: React.CSSProperties = {
height: 6,
background: 'var(--p-surface-border, #333)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'ns-resize',
flexShrink: 0,
}
const dividerHandle: React.CSSProperties = {
const resizeHandlePill: React.CSSProperties = {
width: 32,
height: 2,
borderRadius: 2,