Make graphic panels collapsible and fix hand panning over layers
creative workspace review checks / verify (pull_request) Has been cancelled

This commit is contained in:
2026-10-30 14:28:28 +08:00
parent d362e1a975
commit 4791729b65
4 changed files with 17 additions and 8 deletions
+3 -2
View File
@@ -1,13 +1,14 @@
name: creative workspace review checks
on:
push:
branches: [codex/landing-workspace]
pull_request:
branches: [main]
paths:
- 'apps/studio/**'
- 'apps/waterfall-wiki/**'
- '.gitea/workflows/creative-workspace.yml'
concurrency:
group: creative-workspace-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify:
runs-on: ces-control
+6
View File
@@ -3,6 +3,12 @@ const assert=require('node:assert/strict');
(async()=>{const browser=await chromium.launch({channel:'chrome',headless:true});try{const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));
await page.route('**/api/account/session',r=>r.fulfill({json:{user:null}}));
await page.goto((process.env.STUDIO_TEST_ORIGIN||'http://8.211.154.134:18131')+'/graphic');
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'图层',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换图层面板',exact:true}).click();
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
assert.equal(await page.getByRole('button',{name:'备份设计 JSON',exact:true}).isVisible(),false);
await page.getByRole('button',{name:'切换属性面板',exact:true}).click();
await page.getByRole('button',{name:'添加文字',exact:true}).click();
await page.getByRole('textbox',{name:'文字内容',exact:true}).fill('设计从这里开始');
await page.getByRole('button',{name:'添加矩形',exact:true}).click();
+6 -6
View File
@@ -1,6 +1,6 @@
import Head from 'next/head';
import {useEffect,useRef,useState,type PointerEvent} from 'react';
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X,Hand,Link2,BookOpen} from 'lucide-react';
import {MousePointer2,Type,Square,Circle,Plus,Minus,Undo2,Redo2,Download,Layers,ImagePlus,Trash2,Copy,Eye,EyeOff,Lock,Unlock,Scan,ChevronRight,ArrowUpRight,PenTool,X,Hand,Link2,BookOpen,PanelLeft,PanelRight} from 'lucide-react';
import {newElementId} from '../../lib/native-layout';
import styles from '../../styles/graphic.module.css';
import {parseGraphicDocument,type GraphicItem as Item,type GraphicDocument as Doc} from '../../lib/graphic-document';
@@ -8,7 +8,7 @@ const blank:Doc={name:'未命名设计',background:'#ffffff',items:[]};
const storage='studio.graphic.v1';
export default function Graphic(){
const [doc,setDoc]=useState<Doc>(blank),[selected,setSelected]=useState(''),[past,setPast]=useState<Doc[]>([]),[future,setFuture]=useState<Doc[]>([]),[zoom,setZoom]=useState(.65),[status,setStatus]=useState('本机草稿'),[tab,setTab]=useState('图层'),[user,setUser]=useState<{email:string}|null>(null),[auth,setAuth]=useState(false),[mode,setMode]=useState('login'),[message,setMessage]=useState(''),[busy,setBusy]=useState(false);
const [tool,setTool]=useState('select'),[offset,setOffset]=useState({x:0,y:0});
const [tool,setTool]=useState('select'),[offset,setOffset]=useState({x:0,y:0}),[showLayers,setShowLayers]=useState(true),[showProps,setShowProps]=useState(true);
const dialog=useRef<HTMLDivElement>(null);
useEffect(()=>{if(!auth)return;const old=document.activeElement as HTMLElement;const box=dialog.current;box?.querySelector<HTMLElement>('button,input')?.focus();function key(e:KeyboardEvent){if(e.key==='Escape'){setAuth(false);return}if(e.key!=='Tab'||!box)return;const nodes=Array.from(box.querySelectorAll<HTMLElement>('button:not(:disabled),input,a[href]'));const first=nodes[0],last=nodes[nodes.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus()}}document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);old?.focus()}},[auth]);
const restore=useRef<HTMLInputElement>(null);
@@ -27,15 +27,15 @@ export default function Graphic(){
function duplicate(){if(item&&doc.items.length<500){const id=newElementId();change({...doc,items:[...doc.items,{...item,id,x:item.x+24,y:item.y+24,name:item.name+' 副本',locked:false}]});setSelected(id)}}
function fit(){setOffset({x:0,y:0});if(viewport.current)setZoom(Math.max(.15,Math.min(1,(viewport.current.clientWidth-100)/800,(viewport.current.clientHeight-100)/1000)))}
useEffect(()=>{fit();},[]);
useEffect(()=>{function key(e:KeyboardEvent){if((e.target as HTMLElement).closest('input,textarea,select,button'))return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape')setSelected('');else if(item&&!item.locked&&e.key.startsWith('Arrow')){e.preventDefault();const step=e.shiftKey?10:1;patch({x:item.x+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:item.y+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)});
function drag(e:PointerEvent<HTMLDivElement>,i:Item){e.stopPropagation();setSelected(i.id);if(i.locked||e.button!==0||tool==='hand')return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const x=e.clientX,y=e.clientY,start=doc;let next=start;const target=e.currentTarget;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(n=>n.id===i.id?{...n,x:Math.max(-4000,Math.min(4000,i.x+(p.clientX-x)/zoom)),y:Math.max(-4000,Math.min(4000,i.y+(p.clientY-y)/zoom))}:n)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);}
useEffect(()=>{function key(e:KeyboardEvent){if((e.target as HTMLElement).closest('input,textarea,select,button'))return;if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='z'){e.preventDefault();e.shiftKey?redo():undo()}else if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==='d'){e.preventDefault();duplicate()}else if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();remove()}else if(e.key==='Escape')setSelected('');else if(!e.ctrlKey&&!e.metaKey&&e.key.toLowerCase()==='v')setTool('select');else if(!e.ctrlKey&&!e.metaKey&&e.key.toLowerCase()==='h')setTool('hand');else if(item&&!item.locked&&e.key.startsWith('Arrow')){e.preventDefault();const step=e.shiftKey?10:1;patch({x:item.x+(e.key==='ArrowRight'?step:e.key==='ArrowLeft'?-step:0),y:item.y+(e.key==='ArrowDown'?step:e.key==='ArrowUp'?-step:0)})}}window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key)});
function drag(e:PointerEvent<HTMLDivElement>,i:Item){if(tool==='hand'){panBoard(e);return}e.stopPropagation();setSelected(i.id);if(i.locked||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);const x=e.clientX,y=e.clientY,start=doc;let next=start;const target=e.currentTarget;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(n=>n.id===i.id?{...n,x:Math.max(-4000,Math.min(4000,i.x+(p.clientX-x)/zoom)),y:Math.max(-4000,Math.min(4000,i.y+(p.clientY-y)/zoom))}:n)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end);}
function resize(e:PointerEvent<HTMLElement>){e.stopPropagation();if(!item||item.locked)return;e.preventDefault();const start=doc,x=e.clientX,y=e.clientY,target=e.currentTarget;target.setPointerCapture(e.pointerId);let next=start;const move=(p:globalThis.PointerEvent)=>{next={...start,items:start.items.map(i=>i.id===item.id?{...i,w:Math.max(1,Math.min(4000,item.w+(p.clientX-x)/zoom)),h:Math.max(1,Math.min(4000,item.h+(p.clientY-y)/zoom))}:i)};setDoc(next)};const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end);if(next!==start){setPast(p=>[...p.slice(-49),start]);setFuture([])}};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
function panBoard(e:PointerEvent<HTMLElement>){if(tool!=='hand')return;e.preventDefault();const x=e.clientX,y=e.clientY,start=offset,target=e.currentTarget;target.setPointerCapture(e.pointerId);const move=(p:globalThis.PointerEvent)=>setOffset({x:start.x+p.clientX-x,y:start.y+p.clientY-y});const end=()=>{target.removeEventListener('pointermove',move);target.removeEventListener('pointerup',end);target.removeEventListener('pointercancel',end)};target.addEventListener('pointermove',move);target.addEventListener('pointerup',end);target.addEventListener('pointercancel',end)}
function download(content:string,type:string,extension:string){const url=URL.createObjectURL(new Blob([content],{type}));const a=document.createElement('a');a.href=url;a.download=doc.name+'.'+extension;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
function exportSVG(){const esc=(s:string)=>s.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`<text x="${i.x}" y="${i.y+i.size}" font-family="sans-serif" font-size="${i.size}" fill="${esc(i.fill)}">${i.text.split('\n').map((line,n)=>`<tspan x="${i.x}" dy="${n?i.size*1.3:0}">${esc(line)}</tspan>`).join('')}</text>`:i.kind==='image'?`<image x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" href="${esc(i.src||'')}" preserveAspectRatio="xMidYMid slice"/>`:i.kind==='ellipse'?`<ellipse cx="${i.x+i.w/2}" cy="${i.y+i.h/2}" rx="${i.w/2}" ry="${i.h/2}" fill="${esc(i.fill)}"/>`:`<rect x="${i.x}" y="${i.y}" width="${i.w}" height="${i.h}" fill="${esc(i.fill)}"/>`).join('');download(`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1000" viewBox="0 0 800 1000"><rect width="800" height="1000" fill="${esc(doc.background)}"/>${svg}</svg>`,'image/svg+xml','svg');}
async function account(e:React.FormEvent<HTMLFormElement>){e.preventDefault();setBusy(true);setMessage('');const data=new FormData(e.currentTarget);try{const r=await fetch('/api/account/'+mode,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:data.get('email'),password:data.get('password')})});const d=await r.json();setMessage(d.error||d.message||'登录成功');if(r.ok){await refresh();if(mode==='login')setAuth(false);}}catch{setMessage('账户服务暂时无法连接')}finally{setBusy(false)}}
return <div className={styles.app}><Head><title>{doc.name + " · Studio Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
<header className={styles.top}><a href="/workspace" className={styles.brand}><span>✳</span> studio<span className={styles.badge}>GRAPHIC</span></a><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
return <div className={styles.app} data-layers={showLayers} data-properties={showProps}><Head><title>{doc.name + " · Studio Graphic"}</title><meta name="description" content="在 Studio 中自由编排文字、图形与图片。"/></Head>
<header className={styles.top}><a href="/workspace" className={styles.brand}><span>✳</span> studio<span className={styles.badge}>GRAPHIC</span></a><button aria-label="切换图层面板" aria-pressed={showLayers} onClick={()=>setShowLayers(v=>!v)}><PanelLeft size={17}/></button><button aria-label="切换属性面板" aria-pressed={showProps} onClick={()=>setShowProps(v=>!v)}><PanelRight size={17}/></button><ChevronRight size={14}/><input aria-label="设计名称" value={doc.name} maxLength={100} onChange={e=>change({...doc,name:e.target.value})}/><span className={styles.saved} role="status">{status}</span><button title="撤销" aria-label="撤销" disabled={!past.length} onClick={undo}><Undo2 size={17}/></button><button title="重做" aria-label="重做" disabled={!future.length} onClick={redo}><Redo2 size={17}/></button><button onClick={()=>setAuth(true)} className={styles.account}>{user?user.email:'邮箱登录'}</button><button className={styles.primary} onClick={exportSVG}><Download size={15}/>导出 SVG</button></header>
<div className={styles.body}><aside className={styles.left}><div className={styles.tabs}>{['图层','素材','Agent'].map(t=><button key={t} aria-pressed={tab===t} onClick={()=>setTab(t)}>{t}</button>)}</div><div className={styles.pageTitle}><span>页面 01</span><span>800 × 1000</span></div>{tab==='Agent'?<div className={styles.agentPanel}><Link2 size={24}/><h3>与你的 Agent 一起设计</h3><p>将接入文档交给 coding agent,使用已有的模板与创作 API。</p><a href="/studio/api" target="_blank" rel="noreferrer">查看接入文档 ↗</a><a href="/documents" target="_blank" rel="noreferrer"><BookOpen size={15}/>知识与教程 ↗</a><small>由外部 Agent 发起连接;这里不显示额度或模拟对话。</small></div>:tab==='图层'?<><div className={styles.layers}>{[...doc.items].reverse().map(i=><div className={styles.layer} data-selected={i.id===selected} key={i.id}><button onClick={()=>setSelected(i.id)}>{i.kind==='text'?<Type size={14}/>:<Square size={14}/>}<span>{i.name}</span></button><button aria-label={i.hidden?'显示图层':'隐藏图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,hidden:!n.hidden}:n)})}>{i.hidden?<EyeOff size={13}/>:<Eye size={13}/>}</button><button aria-label={i.locked?'解锁图层':'锁定图层'} onClick={()=>change({...doc,items:doc.items.map(n=>n.id===i.id?{...n,locked:!n.locked}:n)})}>{i.locked?<Lock size={13}/>:<Unlock size={13}/>}</button></div>)}{!doc.items.length&&<div className={styles.emptyLayers}><Layers size={23}/><p>好设计,从第一笔开始</p><small>从下方工具栏添加文字与图形</small></div>}</div><button className={styles.addLayer} onClick={()=>add('text')}><Plus size={15}/>添加图层</button></>:<div className={styles.assets}><h3>基础元素</h3><button onClick={()=>add('rect')}><Square/>矩形</button><button onClick={()=>add('ellipse')}><Circle/>椭圆</button><button onClick={()=>add('text')}><Type/>文字</button><button onClick={()=>upload.current?.click()}><ImagePlus/>上传图片</button><p>你的画布,由你定义。支持 PNG、JPEG、WebP。</p></div>}<div className={styles.leftFooter}><a href="/infinite_canvas">Infinite Canvas <ArrowUpRight size={14}/></a><small>⌘ Z 撤销 · 方向键微调 · Delete 删除</small></div></aside>
<main ref={viewport} className={styles.viewport} onPointerDown={e=>{setSelected('');panBoard(e)}}><div className={styles.canvasLabel}>画板 01 <span>800 × 1000</span></div><div className={styles.boardWrap} style={{width:800*zoom,height:1000*zoom,transform:`translate(${offset.x}px,${offset.y}px)`}}><div ref={board} className={styles.board} style={{background:doc.background,transform:`scale(${zoom})`}}>{doc.items.filter(i=>!i.hidden).map(i=><div key={i.id} tabIndex={0} role="button" aria-label={i.name} aria-pressed={selected===i.id} onFocus={()=>setSelected(i.id)} onPointerDown={e=>drag(e,i)} className={styles.element} data-selected={selected===i.id} style={{left:i.x,top:i.y,width:i.w,height:i.h,borderRadius:i.kind==='ellipse'?'50%':0,background:i.kind==='rect'||i.kind==='ellipse'?i.fill:undefined,color:i.fill,fontSize:i.size,whiteSpace:'pre-wrap'}}>{i.kind==='text'?i.text:i.kind==='image'?<img src={i.src} alt={i.name} draggable={false}/>:null}{selected===i.id&&<><i className={styles.corner} onPointerDown={resize}/><span className={styles.measure}>{Math.round(i.w)} × {Math.round(i.h)}</span></>}</div>)}{!doc.items.length&&<div className={styles.blankHint}><PenTool size={34} strokeWidth={1}/><h1>留白,是创作的起点。</h1><p>添加文字、图形,或放入你的第一张图片。</p><button onPointerDown={e=>e.stopPropagation()} onClick={()=>add('text')}>开始设计 <ArrowUpRight size={16}/></button></div>}</div></div><div className={styles.tools} onPointerDown={e=>e.stopPropagation()}><button title="选择" aria-label="选择工具" aria-pressed={tool==='select'} onClick={()=>{setTool('select');setSelected('')}}><MousePointer2 size={20}/></button><button title="抓手工具" aria-label="抓手工具" aria-pressed={tool==='hand'} onClick={()=>setTool('hand')}><Hand size={19}/></button><button title="文字" aria-label="添加文字" onClick={()=>add('text')}><Type size={20}/></button><button title="矩形" aria-label="添加矩形" onClick={()=>add('rect')}><Square size={19}/></button><button title="椭圆" aria-label="添加椭圆" onClick={()=>add('ellipse')}><Circle size={19}/></button><button title="图片" aria-label="上传图片" onClick={()=>upload.current?.click()}><ImagePlus size={19}/></button><span/><button aria-label="缩小" onClick={()=>setZoom(z=>Math.max(.15,z-.1))}><Minus size={16}/></button><output>{Math.round(zoom*100)}%</output><button aria-label="放大" onClick={()=>setZoom(z=>Math.min(2,z+.1))}><Plus size={16}/></button><button aria-label="适应画布" onClick={fit}><Scan size={18}/></button></div></main>
<aside className={styles.right}><div className={styles.inspectorTitle}>设计属性<span>Design</span></div>{item?<div className={styles.props}><label>图层名称<input maxLength={200} value={item.name} disabled={item.locked} onChange={e=>patch({name:e.target.value})}/></label><h3>布局</h3><div className={styles.fields}>{(['x','y','w','h'] as const).map(k=><label key={k}>{({x:'X',y:'Y',w:'宽',h:'高'})[k]}<input type="number" value={Math.round(item[k])} disabled={item.locked} onChange={e=>{const value=Number(e.target.value);if(Number.isFinite(value))patch({[k]:k==='w'||k==='h'?Math.max(1,Math.min(4000,value)):Math.max(-4000,Math.min(4000,value))})}}/></label>)}</div>{item.kind==='text'&&<><h3>文字</h3><textarea aria-label="文字内容" value={item.text} maxLength={4000} disabled={item.locked} onChange={e=>patch({text:e.target.value})}/><label>字号<input type="number" min={8} max={240} value={item.size} disabled={item.locked} onChange={e=>patch({size:Math.max(8,Math.min(240,Number(e.target.value)||8))})}/></label></>}<h3>填色</h3><label className={styles.color}><input type="color" value={item.fill} disabled={item.locked} onChange={e=>patch({fill:e.target.value})}/>{item.fill.toUpperCase()}</label><div className={styles.actions}><button onClick={duplicate}><Copy size={15}/>复制</button><button disabled={item.locked} onClick={remove}><Trash2 size={15}/>删除</button></div><button disabled={item.locked} onClick={()=>{const n=doc.items.findIndex(i=>i.id===item.id);if(n>0){const items=[...doc.items];[items[n-1],items[n]]=[items[n],items[n-1]];change({...doc,items})}}}>下移一层</button></div>:<div className={styles.props}><h3>画板</h3><p>自由画布 · 竖版</p><div className={styles.dimensions}>W <b>800</b> H <b>1000</b></div><h3>背景颜色</h3><label className={styles.color}><input type="color" value={doc.background} onChange={e=>change({...doc,background:e.target.value})}/>{doc.background.toUpperCase()}</label><div className={styles.note}><MousePointer2 size={20}/><p>选中一个图层,<br/>调整属于它的每个细节。</p></div></div>}<div className={styles.rightFooter}><button onClick={()=>download(JSON.stringify(doc,null,2),'application/json','json')}><Download size={15}/>备份设计 JSON</button><button onClick={()=>restore.current?.click()}>导入设计 JSON</button><p>草稿仅保存在此浏览器。<br/>邮箱登录共用全站账户。</p></div></aside></div>
@@ -2,3 +2,5 @@
/* Compact design chrome, with a neutral stage and contextual selection. */
.app{background:#e5e5e5;color:#303030}.top{height:52px;border-color:#e5e5e5;padding:0 16px;gap:12px}.body{height:calc(100% - 52px);grid-template-columns:248px minmax(240px,1fr) 264px}.brand>span:first-child{color:#1685e8}.app .primary{background:#1685e8}.viewport{background:#e5e5e5;background-image:none;touch-action:none}.boardWrap{will-change:transform}.tabs{padding:10px;gap:4px}.tabs button{flex:1;padding:8px}.tabs button[aria-pressed=true]{background:#f0f0f0;color:#222}.canvasLabel{color:#888}.tools{bottom:16px;border-radius:13px;padding:7px;border-color:#ddd;box-shadow:0 3px 12px #0002}.tools button:first-child{background:transparent;color:inherit}.tools button[aria-pressed=true]{background:#1685e8;color:white}.element[data-selected=true]{outline-color:#1685e8}.corner{border-color:#1685e8;cursor:nwse-resize;width:11px;height:11px}.measure{background:#1685e8}.agentPanel{padding:30px 20px;display:flex;flex-direction:column;gap:18px}.agentPanel>svg{color:#1685e8}.agentPanel h3{font-size:15px;font-weight:500;margin:0}.agentPanel p,.agentPanel small{font-size:11px;line-height:1.9;color:#888}.agentPanel a{display:flex;gap:8px;align-items:center;font-size:12px;color:#1685e8}.agentPanel small{border-top:1px solid #eee;padding-top:20px}.layer[data-selected=true]{background:#e5f2ff}.pageTitle{border-bottom:1px solid #eee}.top .badge{color:#888}@media(max-width:1100px){.body{grid-template-columns:190px minmax(200px,1fr) 220px}}@media(max-width:760px){.body{grid-template-columns:1fr 200px}}@media(max-width:480px){.body{grid-template-columns:1fr}}
.app[data-layers=false] .left,.app[data-properties=false] .right{display:none}.app[data-layers=false] .body{grid-template-columns:minmax(240px,1fr) 264px}.app[data-properties=false] .body{grid-template-columns:248px minmax(240px,1fr)}.app[data-layers=false][data-properties=false] .body{grid-template-columns:1fr}.app[data-layers=false] .tools{left:calc((100% - 264px)/2)}.app[data-properties=false] .tools{left:calc(248px + (100% - 248px)/2)}.app[data-layers=false][data-properties=false] .tools{left:50%}@media(max-width:1100px){.app[data-layers=false] .body{grid-template-columns:minmax(200px,1fr) 220px}.app[data-properties=false] .body{grid-template-columns:190px minmax(200px,1fr)}.app[data-layers=false] .tools{left:calc((100% - 220px)/2)}.app[data-properties=false] .tools{left:calc(190px + (100% - 190px)/2)}}@media(max-width:760px){.app[data-layers=false] .body{grid-template-columns:1fr 200px}.app[data-properties=false] .body{grid-template-columns:1fr}.app[data-properties=false] .tools{left:50%;max-width:calc(100vw - 20px)}}@media(max-width:480px){.app[data-layers=false] .body{grid-template-columns:1fr}.app[data-layers=false] .tools{left:50%}}