@@ -1,6 +1,7 @@
import { useEffect , useRef , useState , type CSSProperties , type KeyboardEvent } from 'react' ;
import { useEffect , useRef , useState , type CSSProperties , type KeyboardEvent , type PointerEvent } from 'react' ;
import { ArrowLeft , MousePointer2 , Type , Square , Circle , ImagePlus , Layers , SlidersHorizontal , Undo2 , Redo2 , Copy , Trash2 , Eye , EyeOff , Lock , Unlock , ChevronUp , ChevronDown , Minus , Plus , Scan , Check , Save } from 'lucide-react' ;
import Head from 'next/head' ;
import type { Layout , Parameters , Slot } from '../lib/layout-parameters' ;
import type { Layout , Parameters , Slot , LayerValue , AddedSlot } from '../lib/layout-parameters' ;
import { parameters } from '../lib/layout-parameters' ;
import { decorationKind , defaultText , designUrl , emptyParameters , imageShape , paperColor , slotPosition , textColor , textSize , type Design } from '../lib/native-layout' ;
@@ -15,11 +16,24 @@ function CurvedCopy({layout,slot,value,style}:{layout:Layout;slot:Slot;value:str
return < div className = "native-copy native-arc" aria-label = { value } style = { style } > { letters . map ( ( letter , index ) = > { const t = index / Math . max ( 1 , letters . length - 1 ) , x = tall ? 90 - 75 * Math . sin ( Math . PI * t ) : 4 + 92 * t , y = tall ? 2 + 94 * t :5 + 75 * Math . sin ( Math . PI * t ) , angle = tall ? 90 + Math . cos ( Math . PI * t ) * 18 :Math.atan2 ( slot . box [ 3 ] * . 75 * Math . PI * Math . cos ( Math . PI * t ) , slot . box [ 2 ] * . 92 ) * 180 / Math . PI ; return < span key = { index } aria-hidden = "true" style = { { left :x + '%' , top :y + '%' , transform : ` translate(-50%,-50%) rotate( ${ angle } deg) ` } } > { letter === ' ' ? '\u00a0' : letter } < / span > } ) } < / div > ;
}
export function LayoutWorkbench ( { layout , design :initialDesign } : LayoutPageProps ) {
const [ params , setParams ] = useState < Parameters > ( initialDesign ? . parameters || emptyParameters ( ) ) , [ design , setDesign ] = useState ( initialDesign ) , [ editing , setEditing ] = useState ( false ) , [ selected , setSelected ] = useState < string | null > ( null ) , [ name , setName ] = useState ( initialDesign ? . name || layout . name ) , [ status , setStatus ] = useState ( '' ) , [ busy , setBusy ] = useState ( false ) , [ dirty , setDirty ] = useState ( false ) , [ history , setHistory ] = useState < Parameters [ ] > ( [ ] ) ;
const upload = useRef < HTMLInputElement > ( null ) , slot = layout . elements . find ( s = > s . id === selected ) ;
const [ params , setParams ] = useState < Parameters > ( initialDesign ? . parameters || emptyParameters ( ) ) , [ design , setDesign ] = useState ( initialDesign ) , [ editing , setEditing ] = useState ( false ) , [ selected , setSelected ] = useState < string | null > ( null ) , [ name , setName ] = useState ( initialDesign ? . name || layout . name ) , [ status , setStatus ] = useState ( '' ) , [ busy , setBusy ] = useState ( false ) , [ dirty , setDirty ] = useState ( false ) , [ history , setHistory ] = useState < Parameters [ ] > ( [ ] ) , [ future , setFuture ] = useState < Parameters [ ] > ( [ ] ) , [ zoom , setZoom ] = useState ( 1 ) , [ panel , setPanel ] = useState < 'layers' | 'properties' | null > ( null ) , [ textEditing , setTextEditing ] = useState < string | null > ( null ) , [ conflict , setConflict ] = useState ( false ) ;
const canvas = useRef < HTMLElement > ( null ) , viewport = useRef < HTMLDivElement > ( null ) ;
const elements = [ . . . layout . elements , . . . ( params . added || [ ] ) ] ;
const upload = useRef < HTMLInputElement > ( null ) , slot = elements . find ( s = > s . id === selected ) ;
useEffect ( ( ) = > { const warn = ( event :BeforeUnloadEvent ) = > { if ( dirty ) { event . preventDefault ( ) ; event . returnValue = '' } } ; window . addEventListener ( 'beforeunload' , warn ) ; return ( ) = > window . removeEventListener ( 'beforeunload' , warn ) } , [ dirty ] ) ;
function patch ( next :Parameters ) { try { const valid = parameters ( layout , next ) ; setHistory ( old = > [ . . . old . slice ( - 39 ) , params ] ) ; setParams ( valid ) ; setDirty ( true ) ; setStatus ( '' ) } catch ( e ) { setStatus ( e instanceof Error ? e . message : '修改失败' ) } }
function patch ( next :Parameters ) { try { const valid = parameters ( layout , next ) ; setHistory ( old = > [ . . . old . slice ( - 39 ) , params ] ) ; setFuture ( [ ] ) ; setParams ( valid ) ; setDirty ( true ) ; setStatus ( '' ) } catch ( e ) { setStatus ( e instanceof Error ? e . message : '修改失败' ) } }
function updateText ( s :Slot , changes :Partial < Parameters [ 'texts' ] [ string ] > ) { patch ( { . . . params , texts : { . . . params . texts , [ s . id ] : { . . . params . texts [ s . id ] , value :params.texts [ s . id ] ? . value ? ? defaultText ( layout , s ) , . . . changes } } } ) }
function layer ( s :Slot ) : LayerValue { return params . layers ? . [ s . id ] || { } }
function updateLayer ( s :Slot , changes :LayerValue ) { patch ( { . . . params , layers : { . . . params . layers , [ s . id ] : { . . . layer ( s ) , . . . changes } } } ) }
function undo ( ) { if ( ! history . length ) return ; setFuture ( f = > [ params , . . . f ] ) ; setParams ( history [ history . length - 1 ] ) ; setHistory ( history . slice ( 0 , - 1 ) ) ; setDirty ( true ) }
function redo ( ) { if ( ! future . length ) return ; setHistory ( h = > [ . . . h , params ] ) ; setParams ( future [ 0 ] ) ; setFuture ( future . slice ( 1 ) ) ; setDirty ( true ) }
function fit ( ) { if ( ! viewport . current ) return ; setZoom ( Math . max ( . 1 , Math . min ( 2 , ( viewport . current . clientWidth - 96 ) / 860 , ( viewport . current . clientHeight - 96 ) / ( 860 * layout . height / layout . width ) ) ) ) }
function add ( kind :string , shape ? : 'rectangle' | 'ellipse' ) { const id = 'user-' + crypto . randomUUID ( ) ; const added :AddedSlot = { id , kind , role :kind === 'text' ? '新文字' : kind === 'image' ? '新图片' : shape === 'ellipse' ? '椭圆' : '矩形' , box : [ layout . width * . 15 , layout . height * . 15 , layout . width * . 3 , layout . height * . 16 ] , direction : 'horizontal' , text :kind === 'text' ? '输入文字' : null , reading : 'custom' , . . . ( shape ? { shape } : { } ) } ; patch ( { . . . params , added : [ . . . ( params . added || [ ] ) , added ] } ) ; setSelected ( id ) ; setPanel ( 'properties' ) }
function remove ( ) { if ( ! slot || layer ( slot ) . locked ) return ; updateLayer ( slot , { hidden :true } ) ; setSelected ( null ) }
function duplicate ( ) { if ( ! slot ) return ; const id = 'user-' + crypto . randomUUID ( ) , l = layer ( slot ) , group = slot . kind === 'text' ? 'texts' : slot . kind === 'image' ? 'images' : 'decorations' ; patch ( { . . . params , added : [ . . . ( params . added || [ ] ) , { . . . slot , id , direction : 'horizontal' , reading : 'custom' , role :slot.role + ' 副本' , text :slot.kind === 'text' ? params . texts [ slot . id ] ? . value ? ? defaultText ( layout , slot ) : null } ] , layers : { . . . params . layers , [ id ] : { . . . l , x : ( l . x ? ? slot . box [ 0 ] ) + 2 , y : ( l . y ? ? slot . box [ 1 ] ) + 2 , locked :false } } , [ group ] : { . . . params [ group ] , [ id ] : params [ group ] [ slot . id ] || ( slot . kind === 'text' ? { value :defaultText ( layout , slot ) } : { } ) } } ) ; setSelected ( id ) }
function pointer ( e :PointerEvent , s :Slot , mode : 'move' | 'resize' | 'rotate' = 'move' ) { if ( ! editing || busy || layer ( s ) . locked || textEditing === s . id || e . button !== 0 ) return ; e . preventDefault ( ) ; e . stopPropagation ( ) ; setSelected ( s . id ) ; const start = params , l = layer ( s ) , rect = canvas . current ! . getBoundingClientRect ( ) , x = e . clientX , y = e . clientY , bx = l . x ? ? s . box [ 0 ] , by = l . y ? ? s . box [ 1 ] , w = l . width ? ? s . box [ 2 ] , h = l . height ? ? s . box [ 3 ] ; const cx = rect . left + ( bx + w / 2 ) / layout . width * rect . width , cy = rect . top + ( by + h / 2 ) / layout . height * rect . height ; let next = start ; const move = ( event :globalThis.PointerEvent ) = > { const dx = ( event . clientX - x ) / rect . width * layout . width , dy = ( event . clientY - y ) / rect . height * layout . height ; let change :LayerValue = mode === 'move' ? { x :bx + dx , y :by + dy } : mode === 'resize' ? { width :Math.max ( . 1 , w + dx ) , height :Math.max ( . 1 , h + dy ) } : { rotation :Math.max ( - 360 , Math . min ( 360 , ( l . rotation || 0 ) + ( Math . atan2 ( event . clientY - cy , event . clientX - cx ) - Math . atan2 ( y - cy , x - cx ) ) * 180 / Math . PI ) ) } ; next = { . . . start , layers : { . . . start . layers , [ s . id ] : { . . . l , . . . change } } } ; setParams ( next ) } ; const end = ( ) = > { window . removeEventListener ( 'pointermove' , move ) ; window . removeEventListener ( 'pointerup' , end ) ; window . removeEventListener ( 'pointercancel' , end ) ; if ( next !== start ) { setHistory ( old = > [ . . . old . slice ( - 39 ) , start ] ) ; setFuture ( [ ] ) ; setDirty ( true ) } } ; window . addEventListener ( 'pointermove' , move ) ; window . addEventListener ( 'pointerup' , end ) ; window . addEventListener ( 'pointercancel' , end ) }
useEffect ( ( ) = > { if ( editing ) { fit ( ) } } , [ editing ] ) ;
useEffect ( ( ) = > { function key ( e :globalThis.KeyboardEvent ) { if ( ! editing ) return ; const typing = ( e . target as HTMLElement ) . closest ( 'input,textarea,select,[contenteditable="plaintext-only"]' ) ; if ( ( e . metaKey || e . ctrlKey ) && e . key . toLowerCase ( ) === 's' ) { e . preventDefault ( ) ; if ( ! busy ) void save ( ) ; return } if ( typing ) 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 ( null ) ; setTextEditing ( null ) } else if ( slot && ! layer ( slot ) . locked && [ 'ArrowLeft' , 'ArrowRight' , 'ArrowUp' , 'ArrowDown' ] . includes ( e . key ) ) { e . preventDefault ( ) ; const step = e.shiftKey?10 :1 , l = layer ( slot ) ; updateLayer ( slot , { x : ( l . x ? ? slot . box [ 0 ] ) + ( e . key === 'ArrowRight' ? step :e.key === 'ArrowLeft' ? - step :0 ) , y : ( l . y ? ? slot . box [ 1 ] ) + ( e . key === 'ArrowDown' ? step :e.key === 'ArrowUp' ? - step :0 ) } ) } } window . addEventListener ( 'keydown' , key ) ; return ( ) = > window . removeEventListener ( 'keydown' , key ) } ) ;
function select ( s :Slot ) { if ( busy ) return ; setEditing ( true ) ; setSelected ( s . id ) }
function reset ( s :Slot ) { const group = s . kind === 'text' ? 'texts' : s . kind === 'image' ? 'images' : 'decorations' , values = { . . . params [ group ] } ; delete values [ s . id ] ; patch ( { . . . params , [ group ] : values } ) }
function keySelect ( e :KeyboardEvent , s :Slot ) { if ( e . key === 'ContextMenu' || ( e . shiftKey && e . key === 'F10' ) ) { e . preventDefault ( ) ; select ( s ) } if ( e . key === 'Escape' ) setSelected ( null ) }
@@ -27,8 +41,8 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
setBusy ( true ) ; try {
const valid = parameters ( layout , params ) , updating = design && ! copy ;
const response = await fetch ( '/api/studio/designs' + ( updating ? '/' + design . id : '' ) , { method :updating? 'PATCH' : 'POST' , headers : { 'Content-Type' : 'application/json' } , body :JSON.stringify ( updating ? { name , parameters :valid , revision :design.revision } : { name , layoutId :layout.id , parameters :valid } ) } ) ;
const data = await response . json ( ) ; if ( ! response . ok ) throw Error ( data . error || '保存失败' ) ;
setDesign ( data . design ) ; setDirty ( false ) ; setEditing ( false ) ; setSelected ( null ) ; setStatus ( '已保存,当前网址可直接打开此页面' ) ;
const data = await response . json ( ) ; if ( ! response . ok ) { if ( response . status === 409 ) setConflict ( true ) ; throw Error ( data . error || '保存失败' ) } ; setConflict ( false ) ;
setDesign ( data . design ) ; setDirty ( false ) ; setStatus ( '已保存,当前网址可直接打开此页面' ) ;
window . history . replaceState ( window . history . state , '' , designUrl ( layout . id , data . design . id ) ) ;
} catch ( e ) { setStatus ( e instanceof Error ? e . message : '保存失败' ) } finally { setBusy ( false ) }
}
@@ -36,36 +50,38 @@ export function LayoutWorkbench({layout,design:initialDesign}:LayoutPageProps){
if ( ! file || ! slot || slot . kind !== 'image' ) return ; const target = slot . id ; setBusy ( true ) ; setStatus ( '正在上传图片…' ) ;
try { const body = new FormData ( ) ; body . append ( 'assetOnly' , 'true' ) ; body . append ( 'kind' , 'image' ) ; body . append ( 'files' , file ) ; const response = await fetch ( '/api/upload' , { method : 'POST' , body } ) ; const data = await response . json ( ) ; if ( ! response . ok || ! data . assets ? . [ 0 ] ) throw Error ( data . error || '上传失败' ) ; patch ( { . . . params , images : { . . . params . images , [ target ] : { . . . params . images [ target ] , assetId :data.assets [ 0 ] } } } ) ; setStatus ( '图片已替换,保存后生效于此网址' ) } catch ( e ) { setStatus ( e instanceof Error ? e . message : '上传失败' ) } finally { setBusy ( false ) ; if ( upload . current ) upload . current . value = '' }
}
const l = slot ? layer ( slot ) : { } , t = slot ? params . texts [ slot . id ] : undefined , im = slot ? params . images [ slot . id ] : undefined ;
const selectedColor = slot ? ( slot . kind === 'text' ? params . texts [ slot . id ] ? . color || textColor ( layout . id , slot ) : slot . kind === 'image' ? params . images [ slot . id ] ? . color || String ( imageShape ( layout , slot ) . backgroundColor ) : params . decorations [ slot . id ] ? . color || '#83908a' ) : '' ;
return < main className = { 'native-layout-page' + ( editing ? ' is-editing' : '' ) } >
< Head > < title > { name } · Prompt < / title > < meta name = "description" content = { layout . name + ' · 可直接编辑的排版页面' } / > < / Head >
< article className = { 'native-layout ' + layout . id } inert = { busy } aria-label = { layout . name } data-layout-id = { layout . id } data-design-id = { design ? . id || undefined } style = { { aspectRatio : ` ${ layout . width } / ${ layout . height } ` , backgroundColor :params.paper || paperColor ( layout . id ) } } >
{ [ 'image' , 'decoration' , 'text' ] . flatMap ( kind = > layout . elements . filter ( s = > s . kind === kind ) . map ( s = > {
const t = params . texts [ s . id ] , im = params . images [ s . id ] , d = params . decorations [ s . id ] , text = t ? . value ? ? defaultText ( layout , s ) , hidden = kind === 'decoration' && d ? . hidden ;
const position = slotPosition ( layout , s ) , common = { onContextMenu : ( e :React.MouseEvent ) = > { e . preventDefault ( ) ; select ( s ) } , onKeyDown : ( e :KeyboardEvent ) = > keySelect ( e , s ) , onClick : ( ) = > { if ( editing ) setSelected ( s . id ) } , onDoubleClick : ( ) = > select ( s ) , tabIndex :editing?0 : undefined , 'data-element' : s . id , 'aria-label' : s . role } ;
{ editing && < header className = "workbench-top" > < a href = "/explore/8eb8c76500580f7a4b56ba3c" aria-label = "返回目录" > < ArrowLeft size = { 18 } / > < / a > < span className = "studio-mark" > P < span > STUDIO < / span > < / span > < input aria-label = "页面名称" value = { name } maxLength = { 120 } onChange = { e = > { setName ( e . target . value ) ; setDirty ( true ) } } / > < span className = "save-indicator" > { busy ? '正在处理…' : dirty ? '有未保存的修改' : design ? '已保存' : '尚未保存' } < / span > < button title = "撤销 ⌘Z" disabled = { ! history . length || busy } onClick = { undo } > < Undo2 size = { 17 } / > < / button > < button title = "重做 ⇧⌘Z" disabled = { ! future . length || busy } onClick = { redo } > < Redo2 size = { 17 } / > < / button > < button onClick = { ( ) = > { setEditing ( false ) ; setTextEditing ( null ) ; setSelected ( null ) } } > 查 看 页 面 < / button > < button className = "primary" disabled = { busy || ! name . trim ( ) } onClick = { ( ) = > void save ( ) } > < Save size = { 15 } / > 保 存 < / button > < / header > }
{ editing && < aside className = { 'workbench-layers ' + ( panel === 'layers' ? 'mobile-open' : '' ) } inert = { busy } > < div className = "panel-heading" > < Layers size = { 16 } / > < strong > 图 层 与 素 材 < / strong > < span > { elements . length } < / span > < / div > < div className = "insert-tools" > < button onClick = { ( ) = > add ( 'text' ) } > < Type / > 文 字 < / button > < button onClick = { ( ) = > add ( 'decoration' , 'rectangle' ) } > < Square / > 矩 形 < / button > < button onClick = { ( ) = > add ( 'decoration' , 'ellipse' ) } > < Circle / > 椭 圆 < / button > < button onClick = { ( ) = > add ( 'image' ) } > < ImagePlus / > 图 片 < / button > < / div > < div className = "layers-caption" > { layout . id . toUpperCase ( ) } < span > 从 上 到 下 排 列 < / span > < / div > < div className = "layer-list" > { [ . . . elements ] . sort ( ( a , b ) = > ( layer ( b ) . z ? ? ( b . kind === 'text' ? 3 :b.kind === 'image' ? 1 :2 ) ) - ( layer ( a ) . z ? ? ( a . kind === 'text' ? 3 :a.kind === 'image' ? 1 :2 ) ) ) . map ( s = > < div key = { s . id } className = { 'layer-row ' + ( selected === s . id ? 'active' : '' ) + ( layer ( s ) . hidden ? ' muted' : '' ) } > < button className = "layer-select" onClick = { ( ) = > { setSelected ( s . id ) ; setTextEditing ( null ) } } > { s . kind === 'text' ? < Type size = { 14 } / > : s . kind === 'image' ? < ImagePlus size = { 14 } / > : < Square size = { 14 } / > } < span > { s . role } < / span > < / button > < button title = "显示或隐藏图层" aria-label = { s . role + '显示或隐藏' } onClick = { ( ) = > updateLayer ( s , { hidden : ! layer ( s ) . hidden } ) } > { layer ( s ) . hidden ? < EyeOff size = { 13 } / > : < Eye size = { 13 } / > } < / button > < button title = "锁定或解锁图层" aria-label = { s . role + '锁定或解锁' } onClick = { ( ) = > updateLayer ( s , { locked : ! layer ( s ) . locked } ) } > { layer ( s ) . locked ? < Lock size = { 13 } / > : < Unlock size = { 13 } / > } < / button > < / div > ) } < / div > < p className = "workbench-tip" > 拖 动 移 动 · 双 击 文 字 改 写 < br / > ⌘ / Ctrl D 复 制 · 方 向 键 微 调 < br / > Shift + 方 向 键 移 动 10 单 位 < / p > < / aside > }
< div ref = { viewport } className = { editing ? 'workbench-viewport' : 'page-viewport' } onClick = { e = > { if ( e . target === e . currentTarget ) { setSelected ( null ) ; setTextEditing ( null ) } } } >
{ editing && < div className = "canvas-caption" > { layout . name } < span > { layout . width } × { layout . height } < / span > < / div > }
< article ref = { canvas } className = { 'native-layout ' + layout . id } inert = { busy } aria-label = { layout . name } data-layout-id = { layout . id } data-design-id = { design ? . id || undefined } style = { { aspectRatio : ` ${ layout . width } / ${ layout . height } ` , backgroundColor :params.paper || paperColor ( layout . id ) , . . . ( editing ? { width :860 * zoom , minWidth :860 * zoom , marginBottom :40 } : { } ) } } >
{ [ 'image' , 'decoration' , 'text' ] . flatMap ( kind = > elements . filter ( s = > s . kind === kind ) . map ( s = > {
const t = params . texts [ s . id ] , im = params . images [ s . id ] , d = params . decorations [ s . id ] , text = t ? . value ? ? defaultText ( layout , s ) , hidden = layer ( s ) . hidden || ( kind === 'decoration' && d ? . hidden ) , ls = layer ( s ) ;
const position = { . . . slotPosition ( layout , { . . . s , box : [ ls . x ? ? s . box [ 0 ] , ls . y ? ? s . box [ 1 ] , ls . width ? ? s . box [ 2 ] , ls . height ? ? s . box [ 3 ] ] } ) , transform : ` rotate( ${ ls . rotation || 0 } deg) ` , opacity :ls.opacity??1 , borderRadius :ls.radius === undefined ? undefined : ` ${ ls . radius / layout . width * 100 } cqw ` , zIndex :ls.z?? ( kind === 'text' ? 3 :kind === 'image' ? 1 :2 ) } , common = { onContextMenu : ( e :React.MouseEvent ) = > { e . preventDefault ( ) ; select ( s ) } , onKeyDown : ( e :KeyboardEvent ) = > keySelect ( e , s ) , onClick : ( ) = > { if ( editing ) setSelected ( s . id ) } , onPointerDown : ( e :PointerEvent ) = > pointer ( e , s ) , onDoubleClick : ( ) = > { select ( s ) ; if ( ! ls . locked && kind === 'text' ) setTextEditing ( s . id ) } , tabIndex :editing?0 : undefined , 'data-element' : s . id , 'aria-label' : s . role } ;
if ( hidden ) return null ;
return < div key = { s . id } { ...common } className = { ` native-element native- ${ kind } ${ selected === s . id && editing ? ' is-selected' : '' } ` } style = { position } >
{ kind === 'text' && s . direction === 'curved' ? < CurvedCopy layout = { layout } slot = { s } value = { text } style = { { fontSize : ` ${ textSize ( layout , s , text ) * ( t ? . scale || 1 ) / layout . width * 100 } cqw ` , color :t?.color || textColor ( layout . id , s ) } } / > : kind === 'text' ? < TextElement className = { 'native-copy direction-' + s . direction } editable = { editing } onDirty = { ( ) = > setDirty ( true ) } value = { text } onInvalid = { ( ) = > setStatus ( '文字最多 500 字、8 行' ) } onChange = { value = > updateText ( s , { value } ) } style = { { fontSize : ` ${ textSize ( layout , s , text ) * ( t ? . scale || 1 ) / layout . width * 100 } cqw ` , color :t?.color || textColor ( layout . id , s ) , textAlign :t?.align === 'middle' ? 'center' : t ? . align === 'end' ? 'right' : 'left' } } / > :
kind === 'image' ? < div className = "native-image-fill" style = { { . . . imageShape ( layout , s ) , . . . ( im ? . color ? { backgroundColor :im.color } : { } ) } } > { im ? . assetId && < img src = { '/api/media/' + im . assetId } alt = { s . role } draggable = { false } style = { { objectFit :im.fit || 'cover' } } / > } { editing && ! im ? . assetId && < span className = "native-image-label" > { s . role } < / span > } < / div > :
< div className = { 'native-decoration decoration-' + decorationKind ( s ) } style = { { color :d?.color || '#83908a' , backgroundColor :decorationKind ( s ) === 'band' ? d ? . color || '#dce1d8' : undefined } } > { decorationKind ( s ) === 'lines' && Array . from ( { length :s.box [ 3 ] > 4?3 :1 } , ( _ , i ) = > < i key = { i } / > ) } { decorationKind ( s ) === 'vertical-lines' && < > < i / > < i / > < i / > < / > } < / div > }
{ kind === 'text' && s . direction === 'curved' ? < CurvedCopy layout = { layout } slot = { s } value = { text } style = { { fontSize : ` ${ ( t ? . fontSize ? ? textSize ( layout , s , text ) * ( t ? . scale || 1 ) ) / layout . width * 100 } cqw ` , color :t?.color || textColor ( layout . id , s ) } } / > : kind === 'text' ? < TextElement className = { 'native-copy direction-' + s . direction } editable = { editing && textEditing === s . id && ! ls . locked } onDirty = { ( ) = > setDirty ( true ) } value = { text } onInvalid = { ( ) = > setStatus ( '文字最多 500 字、8 行' ) } onChange = { value = > updateText ( s , { value } ) } style = { { fontSize : ` ${ ( t ? . fontSize ? ? textSize ( layout , s , text ) * ( t ? . scale || 1 ) ) / layout . width * 100 } cqw ` , color :t?.color || textColor ( layout . id , s ) , fontFamily :t?.fontFamily === 'serif' ? 'Georgia,serif' : t ? . fontFamily === 'mono' ? 'monospace' : t ? . fontFamily === 'sans' ? 'Arial,sans-serif' : undefined , fontWeight :t?.fontWeight , lineHeight :t?.lineHeight , letterSpacing :t?.letterSpacing === undefined ? undefined : ` ${ t . letterSpacing / layout . width * 100 } cqw ` , textAlign :t?.align === 'middle' ? 'center' : t ? . align === 'end' ? 'right' : 'left' } } / > :
kind === 'image' ? < div className = "native-image-fill" style = { { . . . imageShape ( layout , s ) , . . . ( im ? . color ? { backgroundColor :im.color } : { } ) , . . . ( ls . radius !== undefined ? { borderRadius : ` ${ ls . radius / layout . width * 100 } cqw ` } : { } ) } } > { im ? . assetId && < img src = { '/api/media/' + im . assetId } alt = { s . role } draggable = { false } style = { { objectFit :im.fit || 'cover' , objectPosition : ` ${ im . cropX ? ? 50 } % ${ im . cropY ? ? 50 } % ` , transform : ` scale( ${ im . zoom ? ? 1 } ) ` } } / > } { editing && ! im ? . assetId && < span className = "native-image-label" > { s . role } < / span > } < / div > :
< div className = { 'native-decoration decoration-' + ( ( s as AddedSlot ) . shape ? 'solid' : decorationKind ( s ) ) } style = { { borderRadius : ( s as AddedSlot ) . shape === 'ellipse' ? '50%' : undefined , color :d?.color || '#83908a' , backgroundColor : ( s as AddedSlot ) . shape ? d ? . color || '#83908a' : decorationKind ( s ) === 'band' ? d ? . color || '#dce1d8' : undefined } } > { decorationKind ( s ) === 'lines' && Array . from ( { length :s.box [ 3 ] > 4?3 :1 } , ( _ , i ) = > < i key = { i } / > ) } { decorationKind ( s ) === 'vertical-lines' && < > < i / > < i / > < i / > < / > } < / div > }
{ editing && selected === s . id && ! ls . locked && < > < button className = "resize-handle" aria-label = "拖动调整尺寸" onPointerDown = { e = > pointer ( e , s , 'resize' ) } / > < button className = "rotate-handle" aria-label = "拖动旋转" onPointerDown = { e = > pointer ( e , s , 'rotate' ) } > ↻ < / button > < / > }
< / div > ;
} ) ) }
< / article >
< nav className = "page-tools" aria-label = "页面操作" >
< a href = "/explore/8eb8c76500580f7a4b56ba3c" aria-label = "返回排版目录" > ← < / a >
< span className = "page-layout-number" > { layout . id . toUpperCase ( ) } < / span >
{ editing ? < > < button onClick = { ( ) = > { setEditing ( false ) ; setSelected ( null ) } } > 查 看 页 面 < / button > < button disabled = { ! history . length || busy } onClick = { ( ) = > { setParams ( history [ history . length - 1 ] ) ; setHistory ( history . slice ( 0 , - 1 ) ) ; setDirty ( true ) } } > 撤 销 < / button > < button className = "page-save" disabled = { busy } onClick = { ( ) = > void save ( ) } > { busy ? '处理中…' : design ? '保存修改' : '保存页面' } < / button > < / > : < button onClick = { ( ) = > setEditing ( true ) } > 编 辑 页 面 < / button > }
{ dirty && < span className = "page-dirty" aria-label = "尚未保存" > ● < / span > }
< / nav >
{ editing && < div className = "page-edit-info" inert = { busy } > < p > 点 击 文 字 直 接 改 写 ; 点 击 图 形 调 整 。 也 可 以 右 键 选 择 元 素 。 < / p > < label > 页 面 名 称 < input value = { name } maxLength = { 120 } onChange = { e = > { setName ( e . target . value ) ; setDirty ( true ) } } / > < / label > < div className = "page-edit-options" > < label > 纸 色 < input type = "color" aria-label = "纸色" value = { params . paper || paperColor ( layout . id ) } onChange = { e = > patch ( { . . . params , paper :e.target.value } ) } / > < / label > < label > 选 择 元 素 < select aria-label = "选择元素" value = { selected || '' } onChange = { e = > setSelected ( e . target . value || null ) } > < option value = "" > 选 择 元 素 … < / option > { layout . elements . map ( s = > < option value = { s . id } key = { s . id } > { s . role } < / option > ) } < / select > < / label > { design && < button disabled = { busy } onClick = { ( ) = > void save ( true ) } > 另 存 副 本 < / button > } < / div > < / div > }
{ editing && slot && < div className = "element-popover" inert = { busy } role = "dialog" aria-label = { '编辑' + slot . role } >
< div className = "element-popover-heading" > < strong > { slot . role } < / strong > < button aria-label = "关闭元素设置" onClick = { ( ) = > setSelected ( null ) } > × < / button > < / div >
< label > 颜 色 < input type = "color" aria-label = { slot . role + '颜色 '} value = { selectedColor } onChange = { e = > { const color = e . target . value ; if ( slot . kind === 'text' ) updateText ( slot , { color } ) ; else if ( slot . kind === 'image' ) patch ( { . . . params , images : { . . . params . images , [ slot . id ] : { . . . params . images [ slot . id ] , color } } } ) ; else patch ( { . . . params , decorations : { . . . params . decorations , [ slot . id ] : { . . . params . decorations [ slot . id ] , color } } } ) } } / > < / label >
{ slot . kind === 'text' && < > < label > 字 号 < input type = "rang e" min = "0.25" max = "2" step = "0.05" value = { params . texts [ slot . id ] ? . scale || 1 } onChange = { e = > updateText ( slot , { scale :Number ( e . target . value ) } ) } / > < / label > < label > 对 齐 < select value = { params . texts [ slot . id ] ? . align || 'start' } onChange = { e = > updateText ( slot , { align :e.target.value as 'start' | 'middle' | 'end' } ) } > < option value = "start" > 左 对 齐 < / option > < option value = "middle" > 居 中 < / option > < option value = "end" > 右 对 齐 < / option > < / select > < / label > < label className = "element-text-edit" > 文 字 < textarea rows = { 3 } value = { params . texts [ slot . id ] ? . value ? ? defaultText ( layout , slot ) } onChange = { e = > updateText ( slot , { value :e.target.value } ) } / > < / label > < / > }
{ slot . kind === 'image' && < > < button disabled = { busy } onClick = { ( ) = > upload . current ? . click ( ) } > 替 换 图 片 < / button > { params . images [ slot . id ] ? . assetId && < > < label > 图 片 适 应 < select value = { params . images [ slot . id ] ? . fit || 'cover' } onChange = { e = > patch ( { . . . params , images : { . . . params . images , [ slot . id ] : { . . . params . images [ slot . id ] , fit :e.target.value as 'cover' | 'contain' } } } ) } > < option value = "cover" > 填 满 < / option > < option value = "contain" > 完 整 显 示 < / option > < / select > < / label > < button onClick = { ( ) = > { const image = { . . . params . images [ slot . id ] } ; delete image . assetId ; patch ( { . . . params , images : { . . . params . images , [ slot . id ] : image } } ) } } > 移 除 图 片 < / button > < / > } < / > }
{ slot . kind === 'decoration' && < label > 隐 藏 < input type = "checkbox" checked = { params . decorations [ slot . id ] ? . hidden || false } onChange = { e = > patch ( { . . . params , decorations : { . . . params . decorations , [ slot . id ] : { . . . params . decorations [ slot . id ] , hidden :e.target.checked } } } ) } / > < / label > }
< button onClick = { ( ) = > reset ( slot ) } > 恢 复 默 认 < / button >
< / div > }
< / div >
{ ! editing && < nav className = "page-tools" aria-label = "页面操作" > < a href = "/explore/8eb8c76500580f7a4b56ba3c" > ← < / a > < span className = "page-layout-number" > { layout . id . toUpperCase ( ) } < / span > < button onClick = { ( ) = > setEditing ( true ) } > 编 辑 页 面 < / button > { dirty && < span className = "page-dirty" > ● < / span > } < / nav > }
{ editing && < aside className = { 'workbench-properties ' + ( panel === 'properties' ? 'mobile-open' : '' ) } inert = { busy } > < div className = "panel-heading" > < SlidersHorizontal size = { 16 } / > < strong > { slot ? '元素属性' : '页面设置' } < / strong > < span > { slot ? . id || 'Design' } < / span > < / div >
{ ! slot ? < > < section > < h3 > 页 面 < / h3 > < label > 纸 张 颜 色 < input type = "color" value = { params . paper || paperColor ( layout . id ) } onChange = { e = > patch ( { . . . params , paper :e.target.value } ) } / > < / label > < p className = "property-hint" > 选 择 画 布 上 的 元 素 或 左 侧 图 层 , 调 整 它 的 设 计 属 性 。 < / p > < / section > { design && < section > < button onClick = { ( ) = > void save ( true ) } > < Copy size = { 15 } / > 另 存 副 本 < / button > < / section > } < section > < h3 > 快 捷 操 作 < / h3 > < p className = "property-hint" > ⌘ / Ctrl S 保 存 < br / > ⌘ / Ctrl Z 撤 销 < br / > Shift ⌘ / Ctrl Z 重 做 < br / > Delete 隐 藏 所 选 图 层 < br / > Esc 取 消 选 择 < / p > < / section > < / > : < > < section > < h3 > { slot . role } < span > { slot . kind . toUpperCase ( ) } < / span > < / h3 > < div className = "property-grid" > { ( [ 'x' , 'y' , 'width' , 'height' , 'rotation' , 'z' ] as const ) . map ( ( key , i ) = > < label key = { key } > { [ 'X' , 'Y' , '宽' , '高' , '旋转 °' , '层级' ] [ i ] } < input aria-label = { [ 'X' , 'Y' , '宽' , '高' , '旋转' , '层级' ] [ i ] } type = "number" step = "0.1" disabled = { l . locked } value = { Math . round ( ( l [ key ] ? ? ( i < 4 ? slot.box [ i ] : key = = = 'z' ? ( slot.kind = = = 'text' ? 3 : slot.kind = = = 'image' ? 1 : 2 ) : 0 ) ) * 100 ) / 100 } onChange = { e = > updateLayer ( slot , { [ key ] : Number ( e . target . value ) } ) } / > < / label > ) } < / div > < div className = "property-actions" > < button disabled = { l . locked } onClick = { ( ) = > updateLayer ( slot , { z : ( l . z ? ? ( slot . kind === 'text' ? 3 :slot.kind === 'image' ? 1 :2 ) ) + 1 } ) } > < ChevronUp size = { 14 } / > 上移 < / button > < button disabled = { l . locked } onClick = { ( ) = > updateLayer ( slot , { z : ( l . z ? ? ( slot . kind === 'text' ? 3 :slot.kind === 'image' ? 1 :2 ) ) - 1 } ) } > < ChevronDown size = { 14 } / > 下移 < / button > < / div > < / section >
< fieldset disabled = { l . locked } > < section > < h3 > 外观 < / h3 > < label > 颜色 < input type = "color" value = { selectedColor } onChange = { e = > { const color = e . target . value ; if ( slot . kind === 'text' ) updateText ( slot , { color } ) ; else if ( slot . kind === 'image' ) patch ( { . . . params , images : { . . . params . images , [ slot . id ] : { . . . im , color } } } ) ; else patch ( { . . . params , decorations : { . . . params . decorations , [ slot . id ] : { . . . params . decorations [ slot . id ] , color } } } ) } } / > < / label > < div className = "property-grid" > < label > 不透明度 % < input type = "number" min = "0" max = "100" value = { ( l . opacity ? ? 1 ) * 100 } onChange = { e = > updateLayer ( slot , { opacity :Number ( e . target . value ) / 100 } ) } / > < / label > < label > 圆角 < input type = "number" min = "0" max = "5000" value = { l . radius ? ? 0 } onChange = { e = > updateLayer ( slot , { radius :Number ( e . target . value ) } ) } / > < / label > < / div > < / section >
{ slot.kind = = = 'text' & & < section > < h3 > 文字 < / h3 > < textarea aria-label = "文字内容" rows = { 4 } value = { t ? . value ? ? defaultText ( layout , slot ) } onChange = { e = > updateText ( slot , { value :e.target.value } ) } / > < label > 字体 < select value = { t ? . fontFamily || '' } onChange = { e = > updateText ( slot , { fontFamily :e.target.value as 'sans' | 'serif' | 'mono' } ) } > < option value = "" disabled > 模板默认 < / option > < option value = "sans" > 无衬线 < / option > < option value = "serif" > 衬线 < / option > < option value = "mono" > 等宽 < / option > < / select > < / label > < div className = "property-grid" > { ( [ 'fontSize' , 'fontWeight' , 'lineHeight' , 'letterSpacing' ] as const ) .map ( ( key , i ) = > < label key = { key } > { [ '字号' , '字重' , '行距' , '字距' ] [ i ] } < input type = "number" step = { key === 'lineHeight' ? . 1 :key === 'fontWeight' ? 100 :.1 } value = { Math . round ( ( t ? . [ key ] ? ? [ textSize ( layout , slot , t ? . value ? ? defaultText ( layout , slot ) ) * ( t ? . scale || 1 ) , 500 , 1.15 , 0 ] [ i ] ) * 100 ) / 100 } onChange = { e = > updateText ( slot , { [ key ] : Number ( e . target . value ) } ) } / > < / label > ) } < / div > < label > 对 齐 < select value = { t ? . align || 'start' } onChange = { e = > updateText ( slot , { align :e.target.value as 'start' | 'middle' | 'end' } ) } > < option value = "start" > 左 对 齐 < / option > < option value = "middle" > 居 中 < / option > < option value = "end" > 右 对 齐 < / option > < / select > < / label > < / section > }
{ slot . kind === 'image' && < section > < h3 > 图 片 < / h3 > < button onClick = { ( ) = > upload . current ? . click ( ) } > < ImagePlus size = { 15 } / > 上 传 / 替 换 图 片 < / button > < label > 适 应 < select value = { im ? . fit || 'cover' } onChange = { e = > patch ( { . . . params , images : { . . . params . images , [ slot . id ] : { . . . im , fit :e.target.value as 'cover' | 'contain' } } } ) } > < option value = "cover" > 裁 切 填 满 < / option > < option value = "contain " > 完 整 显 示 < / option > < / select > < / label > { ( [ 'cropX' , 'cropY' , 'zoom' ] as const ) . map ( ( key , i ) = > < label key = { key } > { [ '水平裁切' , '垂直裁切' , '图片缩放' ] [ i ] } < input type = "range" min = { i === 2?1 :0 } max = { i === 2?5 :100 } step = { i === 2?.05 :1 } value = { im ? . [ key ] ? ? ( i === 2?1 :50 ) } onChange = { e = > patch ( { . . . params , images : { . . . params . images , [ slot . id ] : { . . . im , [ key ] : Number ( e . target . value ) } } } ) } / > < / label > ) } < / section > } < / fieldset >
< section > < div className = "property-actions" > < button disabled = { l . locked } onClick = { duplicate } > < Copy size = { 14 } / > 复 制 < / button > < button disabled = { l . locked } onClick = { remove } > < Trash2 size = { 14 } / > 删 除 < / button > < / div > < button disabled = { l . locked } onClick = { ( ) = > reset ( slot ) } > 恢 复 内 容 默 认 值 < / button > < / section > < / > }
< / aside > }
{ editing && < nav className = "workbench-bottom" aria-label = "画布工具" > < button className = { panel === 'layers' ? 'active' : ' '} title = "图层面板" onClick = { ( ) = > setPanel ( panel === 'layers' ? null : 'layers' ) } > < Layers size = { 17 } / > < / button > < MousePointer2 size = { 18 } / > < span className = "toolbar-divider" / > < button aria-label = "缩小" onClick = { ( ) = > setZoom ( z = > Math . max ( . 1 , z - . 1 ) ) } > < Minus size = { 16 } / > < / button > < span > { Math . round ( zoom * 100 ) } % < / span > < button aria-label = "放大" onClick = { ( ) = > setZoom ( z = > Math . min ( 3 , z + . 1 ) ) } > < Plus size = { 16 } / > < / button > < button title = "适合窗口" aria-label = "适合窗口" onClick = { fit } > < Scan size = { 17 } / > < / button > < span className = "toolbar-divider" / > < button className = { panel === 'properties' ? 'active' : '' } title = "属性面板" onClick = { ( ) = > setPanel ( panel === 'properties' ? null : 'properties' ) } > < SlidersHorizontal size = { 17 } / > < / button > < / nav > }
{ conflict && < div className = "conflict-notic e" role = "alert" > < strong > 此 页 面 已 有 更 新 < / strong > < p > 本 地 修 改 已 保 留 。 可 另 存 副 本 , 或 重 新 加 载 服 务 器 版 本 。 < / p > < button disabled = { busy } onClick = { ( ) = > void save ( true ) } > 另 存 副 本 < / button > < button onClick = { ( ) = > { if ( window . confirm ( '重新加载将丢弃本地未保存修改,继续?' ) ) { setDirty ( false ) ; window . location . reload ( ) } } } > 重 新 加 载 < / button > < / div > }
< input ref = { upload } type = "file" accept = "image/png,image/jpeg,image/webp,image/gif,image/avif" hidden onChange = { e = > void uploadImage ( e . target . files ? . [ 0 ] ) } / >
{ status && < p className = "page-status" role = "status" > { status } < / p > }
< / main > ;