diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml new file mode 100644 index 0000000..ca7fc48 --- /dev/null +++ b/.gitea/workflows/creative-workspace.yml @@ -0,0 +1,25 @@ +name: creative workspace review checks +on: + 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 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check patch formatting + run: git diff --check HEAD^ HEAD + - name: Verify Studio types, tests, build and smoke + run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" . + - name: Verify Wiki types, tests, build and smoke + run: docker build --target build -f apps/waterfall-wiki/Dockerfile -t "creative-wiki-review:${{ gitea.sha }}" . diff --git a/apps/studio/scripts/graphic-smoke.cjs b/apps/studio/scripts/graphic-smoke.cjs index 975c480..c9a1efd 100644 --- a/apps/studio/scripts/graphic-smoke.cjs +++ b/apps/studio/scripts/graphic-smoke.cjs @@ -2,7 +2,13 @@ const {chromium}=require('playwright-core'); 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://127.0.0.1:3011')+'/studio/graphic'); + 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/scripts/infinite-canvas-smoke.cjs b/apps/studio/scripts/infinite-canvas-smoke.cjs index 84a0d93..495a538 100644 --- a/apps/studio/scripts/infinite-canvas-smoke.cjs +++ b/apps/studio/scripts/infinite-canvas-smoke.cjs @@ -9,9 +9,10 @@ const fs=require('node:fs/promises'); try{ const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined}); const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message)); - await page.goto(base+'/studio/infinite_canvas'); - assert.equal(new URL(page.url()).pathname,'/studio/infinite_canvas','Login required; authenticate without weakening protection'); + await page.goto(base+'/infinite_canvas'); + assert.equal(new URL(page.url()).pathname,'/infinite_canvas','Login required; authenticate without weakening protection'); await page.getByRole('heading',{name:'无限画布 / Infinite canvas'}).waitFor(); + await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click(); // A fresh context is used so this test cannot overwrite a human browser draft. const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1'))); const node=id=>page.locator(`[data-node-id="${id}"]`); @@ -27,7 +28,23 @@ const fs=require('node:fs/promises'); const after=(await snapshot()).cards.find(c=>c.id===id);close(after.x-before.x,45/z);close(after.y-before.y,25/z); } } + // Double-click content enters editing without moving or creating nodes. + const nodeCount=(await snapshot()).cards.length; const before=(await snapshot()).cards.find(c=>c.id==='brief'); + await node('brief').locator('textarea').dblclick({force:true}); + assert.equal(await node('brief').locator('textarea').getAttribute('readonly'),null); + await node('brief').locator('textarea').fill('双击后直接编辑'); + assert.equal((await snapshot()).cards.length,nodeCount); + assert.equal((await snapshot()).cards.find(c=>c.id==='brief').x,before.x); + assert.equal((await snapshot()).cards.find(c=>c.id==='brief').y,before.y); + await page.keyboard.press('Escape'); + const chooser=page.waitForEvent('filechooser'); + await node('reference').getByRole('button',{name:/替换.*图片/}).dblclick(); + const imagePicker=await chooser; + await imagePicker.setFiles({name:'replacement.png',mimeType:'image/png',buffer:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jC1sAAAAASUVORK5CYII=','base64')}); + await page.waitForFunction(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(c=>c.id==='reference').image?.startsWith('data:image/png;base64,')); + assert.equal((await snapshot()).cards.length,nodeCount); + await page.keyboard.press('Escape'); assert.equal(await node('brief').locator('textarea').getAttribute('readonly'),'');await node('brief').getByRole('button',{name:/^编辑 /}).click();await node('brief').locator('textarea').fill('真实工具内编辑 · 输入不能移动节点'); const edited=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(edited.x,before.x);assert.equal(edited.y,before.y); await drag(node('brief').locator('textarea'),20,10);const inEdit=(await snapshot()).cards.find(c=>c.id==='brief');assert.equal(inEdit.x,edited.x);assert.equal(inEdit.y,edited.y);await node('brief').getByRole('button',{name:/^完成编辑 /}).click();const resize=node('brief').getByRole('button',{name:/调整/});await drag(resize,60,40); diff --git a/apps/studio/src/pages/graphic/index.tsx b/apps/studio/src/pages/graphic/index.tsx index ae8bc15..456034f 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} 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,6 +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}),[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); @@ -24,17 +25,19 @@ export default function Graphic(){ function redo(){if(!future.length)return;setPast(p=>[...p,doc]);setDoc(future[0]);setFuture(f=>f.slice(1));} function remove(){if(item&&!item.locked){change({...doc,items:doc.items.filter(i=>i.id!==selected)});setSelected('')}} 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(){if(viewport.current)setZoom(Math.max(.15,Math.min(1,(viewport.current.clientWidth-100)/800,(viewport.current.clientHeight-100)/1000)))} + 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)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}
-
-
setSelected('')}>
画板 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)}%
+ 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)}%