2026-10-26 09:43:17 +08:00
/* V8.22 trusted, sandboxed studio canvas bridge. No arbitrary user code. */
( function (){
'use strict' ;
const source = 'siteforge-studio-canvas' ;
const list = ()=> Array . from ( document . querySelectorAll ( '.sf-section[data-block]' )). filter ( n => ! n . parentElement . closest ( '.sf-section[data-block]' ));
const send = ( action , id , extra = {})=> parent . postMessage ({ source , action , blockId : id ,... extra }, '*' );
let active = '' , dragging = '' , resize = null ;
const all = ()=> Array . from ( document . querySelectorAll ( '.sf-section[data-block]' ));
const style = document . createElement ( 'style' );
style . textContent = `
.sf-section[data-block]{position:relative}
.sf-section[data-block].sf-canvas-selected{outline:2px solid #ec7c35!important;outline-offset:-2px}
.sf-section[data-block].sf-canvas-drop{box-shadow:inset 0 4px 0 #ef8741!important}
.sf-section[data-block]:hover{outline:1px dashed #eb995a;outline-offset:-1px}
.sf-canvas-toolbar{position:absolute;right:7px;top:5px;z-index:2147480000;display:none;align-items:center;gap:3px;padding:5px;background:#263447;color:#fff;border-radius:9px;box-shadow:0 5px 20px #20304450;font:12px/1.4 system-ui,sans-serif;white-space:nowrap;pointer-events:auto}
.sf-canvas-selected>.sf-canvas-toolbar,.sf-section[data-block]:hover>.sf-canvas-toolbar,.sf-canvas-toolbar:focus-within{display:flex}
.sf-canvas-toolbar button{background:transparent;color:inherit;border:0;cursor:pointer;padding:5px 7px;border-radius:5px;font:inherit}
.sf-canvas-toolbar button:hover,.sf-canvas-toolbar button:focus-visible{background:#52687e;outline:1px solid #fff6}
.sf-canvas-toolbar .sf-canvas-drag{cursor:grab;background:#496174}
.sf-canvas-toolbar .sf-canvas-danger:hover{background:#ab4d46}
.sf-section.sf-canvas-child-selected{outline:2px solid #0e97ac!important;outline-offset:-2px}
.sf-nested-toolbar{position:absolute;top:6px;left:6px;z-index:2147480001;display:none;gap:3px;background:#126c80;color:#fff;padding:5px;border-radius:8px;font:12px system-ui;pointer-events:auto}
.sf-canvas-child-selected>.sf-nested-toolbar,.sf-section[data-block]:hover>.sf-nested-toolbar,.sf-nested-toolbar:focus-within{display:flex}
.sf-nested-toolbar button{color:#fff;background:transparent;border:0;border-radius:5px;cursor:pointer;padding:5px 7px}
.sf-nested-toolbar button:hover,.sf-nested-toolbar button:focus-visible{background:#248ea4;outline:1px solid #fff6}
.sf-canvas-hint{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);background:#1c293ee8;color:white;padding:9px 13px;border-radius:9px;z-index:2147480000;font:12px system-ui;pointer-events:none}
.sf-layout-grid{position:relative}
.sf-canvas-resize{position:absolute;z-index:2147480000;top:4px;bottom:4px;width:12px;transform:translateX(-50%);cursor:col-resize;touch-action:none;opacity:0;display:flex;align-items:center;justify-content:center}
.sf-canvas-resize:after{content:'⋮';background:#e77632;color:white;box-shadow:0 2px 8px #2225;border-radius:8px;font:20px/26px system-ui;text-align:center;width:19px;min-width:19px}
.sf-section:hover .sf-canvas-resize,.sf-canvas-resize:focus-visible,.sf-canvas-resize.sf-resizing{opacity:1}
[contenteditable].sf-canvas-editing{outline:2px dashed #df7435!important;border-radius:3px;cursor:text!important}
@media(max-width:820px){.sf-canvas-resize{display:none!important}}
` ;
document . head . appendChild ( style );
const types = { title : 'h1,h2' , description : '.sf-hero-copy p,.sf-image-text p,.sf-cta-wrap p' };
function activate ( id , notify = true ){
active = id || '' ; for ( const el of all ()) el . classList . toggle ( 'sf-canvas-selected' , el . dataset . block === id );
if ( notify && id ) send ( 'select' , id );
}
const topSections = list ();
for ( const block of topSections ){
const id = block . dataset . block ;
const bar = document . createElement ( 'div' ); bar . className = 'sf-canvas-toolbar' ; bar . setAttribute ( 'role' , 'toolbar' ); bar . setAttribute ( 'aria-label' , '区块快捷操作' );
const controls = [[ '↕' , '拖动区块' , 'drag' ],[ '✎' , '编辑区块' , 'edit' ],[ '↑' , '上移区块' , 'up' ],[ '↓' , '下移区块' , 'down' ],[ '+ ' , '在下方添加区块' , 'add-after' ],[ '⧉' , '复制区块' , 'duplicate' ],[ '× ' , '删除区块' , 'delete' ]];
for ( const [ txt , label , action ] of controls ){
const b = document . createElement ( 'button' ); b . type = 'button' ; b . textContent = txt ; b . title = label ; b . setAttribute ( 'aria-label' , label ); b . dataset . action = action ;
if ( action === 'drag' ){ b . draggable = true ; b . className = 'sf-canvas-drag' ; b . addEventListener ( 'dragstart' , e =>{ dragging = id ; e . dataTransfer . setData ( 'text/plain' , id ); e . dataTransfer . effectAllowed = 'move' ; e . stopPropagation ()}); b . addEventListener ( 'dragend' ,()=>{ dragging = '' ; list (). forEach ( el => el . classList . remove ( 'sf-canvas-drop' ))})}
else { if ( action === 'delete' ) b . className = 'sf-canvas-danger' ; b . addEventListener ( 'click' , e =>{ e . preventDefault (); e . stopPropagation (); activate ( id , false ); send ( action , id )})}
bar . appendChild ( b );
}
block . prepend ( bar );
block . addEventListener ( 'click' , event =>{
if ( event . target . closest ( '.sf-canvas-toolbar,.sf-canvas-resize' )) return ;
if ( event . target . closest ( 'a' )) event . preventDefault ();
activate ( id );
});
block . addEventListener ( 'dragover' , event =>{ if ( ! dragging ) return ; event . preventDefault (); event . stopPropagation (); block . classList . add ( 'sf-canvas-drop' )});
block . addEventListener ( 'dragleave' , event =>{ if ( ! block . contains ( event . relatedTarget )) block . classList . remove ( 'sf-canvas-drop' )});
block . addEventListener ( 'drop' , event =>{ if ( ! dragging ) return ; event . preventDefault (); event . stopPropagation (); block . classList . remove ( 'sf-canvas-drop' ); if ( dragging !== id ) send ( 'reorder' , dragging ,{ targetId : id }); dragging = '' });
}
// Rich text is not edited as plain text: modifying sanitized HTML would lose formatting.
for ( const section of topSections ){
const id = section . dataset . block ;
for ( const [ field , selector ] of Object . entries ( types )){
const target = section . querySelector ( selector ); if ( ! target || target . closest ( '.sf-section[data-block]' ) !== section ) continue ;
target . title = '双击直接修改' + ( field === 'title' ? '标题' : '描述' );
target . addEventListener ( 'dblclick' , event =>{
event . stopPropagation (); activate ( id ); const before = target . textContent ;
target . contentEditable = 'true' ; target . classList . add ( 'sf-canvas-editing' ); target . focus ();
let closed = false ;
const finish = commit =>{
if ( closed ) return ; closed = true ; target . removeAttribute ( 'contenteditable' ); target . classList . remove ( 'sf-canvas-editing' );
const value = target . textContent . replace ( /\r/g , '' ). trim ();
if ( ! commit ) target . textContent = before ;
else if ( value !== before && value . length <= 500 ) send ( 'inline-text' , id ,{ field , value });
else if ( value . length > 500 ) target . textContent = before ;
};
target . addEventListener ( 'blur' ,()=> finish ( true ),{ once : true });
target . addEventListener ( 'keydown' , e =>{ if ( e . key === 'Escape' ){ e . preventDefault (); finish ( false ); target . blur ()} if ( e . key === 'Enter' &&! e . shiftKey ){ e . preventDefault (); target . blur ()} });
});
}
}
// Children of every nested layout (including second-level content) are selectable. The parent
// checks IDs against the current schema and rejects forged/obsolete events.
const nested = Array . from ( document . querySelectorAll ( '.sf-layout-column > .sf-section[data-block]' ));
for ( const child of nested ){
const layout = child . closest ( '.sf-layout-grid[data-layout]' );
if ( ! layout ) continue ;
const parentId = layout . dataset . layout , childId = child . dataset . block ;
const push = ( action , extra = {})=> send ( action , parentId ,{ childId ,... extra });
const toolbar = document . createElement ( 'div' ); toolbar . className = 'sf-nested-toolbar' ; toolbar . setAttribute ( 'role' , 'toolbar' ); toolbar . setAttribute ( 'aria-label' , '列内组件操作' );
for ( const [ symbol , label , action ] of [[ '✎' , '编辑子组件' , 'child-edit' ],[ '↑' , '上移子组件' , 'child-up' ],[ '↓' , '下移子组件' , 'child-down' ],[ '⧉' , '复制子组件' , 'child-duplicate' ],[ '× ' , '删除子组件' , 'child-delete' ]]){
const button = document . createElement ( 'button' ); button . type = 'button' ; button . textContent = symbol ; button . setAttribute ( 'aria-label' , label );
button . addEventListener ( 'click' , event =>{ event . preventDefault (); event . stopPropagation (); push ( action )});
toolbar . appendChild ( button );
}
child . prepend ( toolbar );
child . addEventListener ( 'click' , event =>{ if ( event . target . closest ( '.sf-nested-toolbar' )) return ; if ( event . target . closest ( 'a' )) event . preventDefault (); event . stopPropagation (); push ( 'child-select' )});
// Only plain text fields are inline editable; rich HTML remains in the form.
for ( const [ field , selector ] of Object . entries ( types )){
const node = child . querySelector ( selector );
if ( ! node || node . closest ( '.sf-section[data-block]' ) !== child ) continue ;
node . title = '双击编辑列内' + ( field === 'title' ? '标题' : '描述' );
node . addEventListener ( 'dblclick' , event =>{
event . stopPropagation (); push ( 'child-select' ); const original = node . textContent ;
node . contentEditable = 'true' ; node . classList . add ( 'sf-canvas-editing' ); node . focus (); let complete = false ;
const finish = save =>{ if ( complete ) return ; complete = true ; node . removeAttribute ( 'contenteditable' ); node . classList . remove ( 'sf-canvas-editing' ); const text = node . textContent . replace ( /\r/g , '' ). trim (); if ( ! save || text . length > 500 ) node . textContent = original ; else if ( text !== original ) push ( 'child-inline-text' ,{ field , value : text })};
node . addEventListener ( 'blur' ,()=> finish ( true ),{ once : true });
node . addEventListener ( 'keydown' , e =>{ if ( e . key === 'Escape' ){ e . preventDefault (); finish ( false ); node . blur ()} if ( e . key === 'Enter' &&! e . shiftKey ){ e . preventDefault (); node . blur ()}});
});
}
}
// Desktop-only handles keep the total adjacent width stable and never free-position blocks.
for ( const grid of document . querySelectorAll ( '.sf-layout-grid[data-layout]' )){
const id = grid . dataset . layout ;
const cols = Array . from ( grid . children ). filter ( el => el . classList . contains ( 'sf-layout-column' ));
if ( cols . length < 2 ) continue ;
for ( let i = 0 ; i < cols . length - 1 ; i ++ ){
const handle = document . createElement ( 'div' ); handle . className = 'sf-canvas-resize' ; handle . tabIndex = 0 ; handle . setAttribute ( 'role' , 'separator' ); handle . setAttribute ( 'aria-label' , `拖动调整第 ${ i + 1 } 列宽度` );
const place = ()=>{ const a = cols [ i ]. getBoundingClientRect (), g = grid . getBoundingClientRect (); handle . style . left = ( a . right - g . left + parseFloat ( getComputedStyle ( grid ). columnGap || '0' ) / 2 ) + 'px' };
grid . appendChild ( handle ); place ();
handle . addEventListener ( 'pointerdown' , e =>{
if ( e . button !== 0 || matchMedia ( '(max-width:820px)' ). matches ) return ;
e . stopPropagation (); e . preventDefault (); handle . setPointerCapture ( e . pointerId ); handle . classList . add ( 'sf-resizing' );
const raw = grid . style . getPropertyValue ( '--sf-layout-cols' ). trim (). split ( /\s+/ ). map ( x => parseFloat ( x ) || 1 );
const start = e . clientX , span = Math . max ( 50 , cols [ i ]. getBoundingClientRect (). width + cols [ i + 1 ]. getBoundingClientRect (). width );
resize = { id , i , raw , start , span }; activate ( id );
});
handle . addEventListener ( 'pointermove' , e =>{
if ( ! resize || resize . i !== i ) return ;
const v = resize . raw . slice (), total = v [ i ] + v [ i + 1 ]; const delta = ( e . clientX - resize . start ) / resize . span * total ;
v [ i ] = Math . round ( Math . min ( 4 , Math . max (. 5 , resize . raw [ i ] + delta )) * 100 ) / 100 ;
v [ i + 1 ] = Math . round ( Math . min ( 4 , Math . max (. 5 , total - v [ i ])) * 100 ) / 100 ;
if ( Math . abs ( v [ i ] + v [ i + 1 ] - total ) > 0.011 ) return ;
grid . style . setProperty ( '--sf-layout-cols' , v . map ( x => x + 'fr' ). join ( ' ' )); place ();
});
const done = e =>{ if ( ! resize || resize . i !== i ) return ; const raw = grid . style . getPropertyValue ( '--sf-layout-cols' ). trim (). split ( /\s+/ ). map ( x => parseFloat ( x ) || 1 ); resize = null ; handle . classList . remove ( 'sf-resizing' ); send ( 'resize-columns' , id ,{ widths : raw });};
handle . addEventListener ( 'pointerup' , done ); handle . addEventListener ( 'pointercancel' ,()=>{ resize = null ; handle . classList . remove ( 'sf-resizing' )});
handle . addEventListener ( 'keydown' , e =>{ if ( e . key !== 'ArrowLeft' && e . key !== 'ArrowRight' ) return ; e . preventDefault (); const w = grid . style . getPropertyValue ( '--sf-layout-cols' ). trim (). split ( /\s+/ ). map ( x => parseFloat ( x ) || 1 ); const delta = e . key === 'ArrowRight' ? . 1 :- . 1 ; const total = w [ i ] + w [ i + 1 ]; const first = Math . round ( Math . min ( 4 , Math . max (. 5 , w [ i ] + delta )) * 100 ) / 100 , second = Math . round (( total - first ) * 100 ) / 100 ; if ( second < . 5 || second > 4 ) return ; w [ i ] = first ; w [ i + 1 ] = second ; grid . style . setProperty ( '--sf-layout-cols' , w . map ( x => x + 'fr' ). join ( ' ' )); place (); send ( 'resize-columns' , id ,{ widths : w })});
new ResizeObserver ( place ). observe ( grid );
}
}
window . addEventListener ( 'message' , event =>{
if ( event . source !== parent || event . data ? . source !== 'siteforge-studio-host' ) return ;
2026-10-28 15:00:01 +08:00
if ( event . data . action === 'scroll-to-blocks' ){
const main = document . querySelector ( '#main-content' );
if ( main && main . children . length > 0 ){ main . scrollIntoView ({ behavior : 'auto' , block : 'start' })}
}
2026-10-26 09:43:17 +08:00
if ( event . data . action === 'highlight' && typeof event . data . blockId === 'string' ){ activate ( event . data . blockId , false ); for ( const n of nested ) n . classList . toggle ( 'sf-canvas-child-selected' , n . dataset . block === event . data . childId );}
});
})();