diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml new file mode 100644 index 0000000..0844e89 --- /dev/null +++ b/.gitea/workflows/creative-workspace.yml @@ -0,0 +1,22 @@ +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' +jobs: + verify: + runs-on: ces-control + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - 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/src/pages/graphic/index.tsx b/apps/studio/src/pages/graphic/index.tsx index ae8bc15..94d534b 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} 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}); 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);} + 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);} + 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)}%
+
✳ 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)}%