From 4791729b65c7bb5ac7c4e37d503aaaccde3f3256 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 30 Oct 2026 14:28:28 +0800 Subject: [PATCH] Make graphic panels collapsible and fix hand panning over layers --- .gitea/workflows/creative-workspace.yml | 5 +++-- apps/studio/scripts/graphic-smoke.cjs | 6 ++++++ apps/studio/src/pages/graphic/index.tsx | 12 ++++++------ apps/studio/src/styles/graphic.module.css | 2 ++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml index 3d76c79..ca7fc48 100644 --- a/.gitea/workflows/creative-workspace.yml +++ b/.gitea/workflows/creative-workspace.yml @@ -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 diff --git a/apps/studio/scripts/graphic-smoke.cjs b/apps/studio/scripts/graphic-smoke.cjs index 6808b23..c9a1efd 100644 --- a/apps/studio/scripts/graphic-smoke.cjs +++ b/apps/studio/scripts/graphic-smoke.cjs @@ -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(); diff --git a/apps/studio/src/pages/graphic/index.tsx b/apps/studio/src/pages/graphic/index.tsx index 94d534b..ebec387 100644 --- a/apps/studio/src/pages/graphic/index.tsx +++ b/apps/studio/src/pages/graphic/index.tsx @@ -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(blank),[selected,setSelected]=useState(''),[past,setPast]=useState([]),[future,setFuture]=useState([]),[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(null); useEffect(()=>{if(!auth)return;const old=document.activeElement as HTMLElement;const box=dialog.current;box?.querySelector('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('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(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,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,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){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){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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!));const svg=doc.items.filter(i=>!i.hidden).map(i=>i.kind==='text'?`${i.text.split('\n').map((line,n)=>`${esc(line)}`).join('')}`:i.kind==='image'?``:i.kind==='ellipse'?``:``).join('');download(`${svg}`,'image/svg+xml','svg');} async function account(e:React.FormEvent){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
{doc.name + " · Studio Graphic"} -
✳ studioGRAPHICchange({...doc,name:e.target.value})}/>{status}
+ return
{doc.name + " · Studio Graphic"} +
✳ studioGRAPHICchange({...doc,name:e.target.value})}/>{status}
{setSelected('');panBoard(e)}}>
画板 01 800 × 1000
{doc.items.filter(i=>!i.hidden).map(i=>
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'?{i.name}:null}{selected===i.id&&<>{Math.round(i.w)} × {Math.round(i.h)}}
)}{!doc.items.length&&

留白,是创作的起点。

添加文字、图形,或放入你的第一张图片。

}
e.stopPropagation()}>{Math.round(zoom*100)}%