Files
cms-SiteForge/app/static/editor-helpers.js
T

154 lines
7.7 KiB
JavaScript
Raw Normal View History

/* Safe, deterministic editor operations. Exported for Node regression tests. */
(function (root, factory) {
const api=factory();
if(typeof module==='object' && module.exports)module.exports=api;
root.SiteForgeEditorHelpers=api;
})(typeof window!=='undefined'?window:globalThis, function(){
'use strict';
const deepCopy=value=>JSON.parse(JSON.stringify(value));
function reorder(items, from, to){
if(!Array.isArray(items)||!Number.isInteger(from)||!Number.isInteger(to)||from<0||to<0||from>=items.length||to>=items.length)return null;
if(from===to)return items.slice();
const next=items.slice();next.splice(to,0,next.splice(from,1)[0]);return next;
}
function duplicate(block, idFactory){
if(!block || typeof idFactory!=='function')throw Error('无效的区块');
const item=deepCopy(block);
const rewrite=node=>{
node.id=idFactory();
if(node.type==='layout')for(const col of node.columns||[])for(const child of col.blocks||[])rewrite(child);
};
rewrite(item);return item;
}
function filterBlocks(blocks, term, names={}){
const query=String(term||'').trim().toLocaleLowerCase();
if(!query)return blocks.map((block,index)=>({block,index}));
return blocks.flatMap((block,index)=>{
const description=[block.title,block.subtitle,block.type,names[block.type]?.name]
.concat(block.type==='layout'?(block.columns||[]).flatMap(c=>(c.blocks||[]).map(b=>b.title||b.type)):[])
.filter(x=>typeof x==='string').join(' ').toLocaleLowerCase();
return description.includes(query)?[{block,index}]:[];
});
}
function historyState(snapshots,cursor){return {canUndo:cursor>0,canRedo:cursor>=0&&cursor<snapshots.length-1};}
function clampNumber(value,min,max,fallback){const n=Number(value);return Number.isFinite(n)?Math.max(min,Math.min(max,n)):fallback;}
function shouldAcceptPreview(requestedPage,requestedSeq,currentPage,currentSeq){
return requestedPage===currentPage&&requestedSeq===currentSeq;
}
function findTopIndex(rows,id){return Array.isArray(rows)&&typeof id==='string'?rows.findIndex(b=>b.id===id):-1;}
function reorderById(rows,sourceId,targetId){
const from=findTopIndex(rows,sourceId),to=findTopIndex(rows,targetId);
return from<0||to<0||from===to?null:reorder(rows,from,to);
}
function adjacentWidths(block,widths){
if(!block||block.type!=='layout'||!Array.isArray(block.columns)||!Array.isArray(widths)||widths.length!==block.columns.length)return null;
const validated=widths.map(v=>Number(v));
if(validated.some(v=>!Number.isFinite(v)||v<.5||v>4))return null;
// Keep the same type and field shape expected by blueprints.validate_blocks.
return validated.map(v=>Math.round(v*100)/100);
}
function safeCanvasCommand(command,rows,types){
if(!command||typeof command!=='object'||typeof command.blockId!=='string'||command.blockId.length>150)return false;
const i=findTopIndex(rows,command.blockId);
if(i<0)return false;
const actions=['select','edit','up','down','add-after','duplicate','delete','reorder','resize-columns','inline-text'];
if(!actions.includes(command.action))return false;
if(command.action==='reorder')return findTopIndex(rows,command.targetId)>=0;
if(command.action==='resize-columns')return !!adjacentWidths(rows[i],command.widths);
if(command.action==='inline-text')return (command.field==='title'||command.field==='description')&&
typeof command.value==='string'&&command.value.length<=500&&types?.[rows[i].type]?.fields?.includes(command.field);
return true;
}
// V8.24: resolve the actual parent of a nested child from the validated tree.
// A client message supplies stable IDs, never trusted indexes or an editable path.
// Renderer accepts at most two layout levels; a bounded walk avoids malformed cycles.
function findNestedChild(rows,parentId,childId){
if(typeof parentId!=='string'||typeof childId!=='string'||!Array.isArray(rows)||
!parentId||!childId||parentId===childId)return null;
for(let rootIndex=0;rootIndex<rows.length;rootIndex++){
const root=rows[rootIndex];
function visit(layout,depth,ancestors){
if(!layout||layout.type!=='layout'||!Array.isArray(layout.columns)||depth>2)return null;
for(let ci=0;ci<layout.columns.length;ci++){
const children=layout.columns[ci]?.blocks;
if(!Array.isArray(children))continue;
for(let ri=0;ri<children.length;ri++){
const child=children[ri];
if(!child||typeof child!=='object')continue;
if(layout.id===parentId&&child.id===childId)
return {parentIndex:rootIndex,root,parent:layout,columnIndex:ci,
childIndex:ri,child,depth,ancestors:[...ancestors,layout.id]};
if(child.type==='layout'&&depth<2){
const found=visit(child,depth+1,[...ancestors,layout.id]);
if(found)return found;
}
}
}
return null;
}
const found=visit(root,0,[]);
if(found)return found;
}
return null;
}
function findChildInRoot(rows,rootId,childId){
const rootIndex=findTopIndex(rows,rootId);
if(rootIndex<0||typeof childId!=='string')return null;
const root=rows[rootIndex];
if(root.type!=='layout')return null;
function visit(layout,depth){
if(depth>2)return null;
for(const col of layout.columns||[]){
for(const child of col.blocks||[]){
if(child?.id===childId)return findNestedChild(rows,layout.id,childId);
if(child?.type==='layout'){
const match=visit(child,depth+1);
if(match)return match;
}
}
}
return null;
}
return visit(root,0);
}
function findLayout(rows,layoutId){
if(typeof layoutId!=='string'||!Array.isArray(rows))return null;
const topIndex=findTopIndex(rows,layoutId);
if(topIndex>=0)return rows[topIndex].type==='layout'?{layout:rows[topIndex],rootIndex:topIndex,depth:0}:null;
for(let i=0;i<rows.length;i++){
const root=rows[i];
if(root.type!=='layout')continue;
for(const col of root.columns||[])for(const child of col.blocks||[]){
if(child?.id===layoutId && child.type==='layout')return {layout:child,rootIndex:i,depth:1};
}
}
return null;
}
function safeNestedCanvasCommand(command,rows,types){
if(!command||typeof command!=='object')return false;
const found=findNestedChild(rows,command.blockId,command.childId);
if(!found)return false;
const actions=['child-select','child-edit','child-up','child-down','child-duplicate','child-delete','child-inline-text'];
if(!actions.includes(command.action))return false;
if(command.action==='child-inline-text')return ['title','subtitle','description'].includes(command.field)
&& types?.[found.child.type]?.fields?.includes(command.field)
&& typeof command.value==='string'&&command.value.length<=500;
return true;
}
function layoutPreset(block,preset){
if(!block||block.type!=='layout'||!Array.isArray(block.columns))return null;
const count=block.columns.length;if(count<1||count>4)return null;
if(preset==='equal')return Array(count).fill(1);
if(count!==2)return null;
if(preset==='left-wide')return [2,1];
if(preset==='right-wide')return [1,2];
return null;
}
function moveNested(block,columnIndex,childIndex,direction){
if(!block||block.type!=='layout'||![-1,1].includes(direction))return null;
const list=block.columns?.[columnIndex]?.blocks;
return Array.isArray(list)?reorder(list,childIndex,childIndex+direction):null;
}
return {reorder,duplicate,filterBlocks,historyState,clampNumber,shouldAcceptPreview,findTopIndex,reorderById,adjacentWidths,safeCanvasCommand,findNestedChild,findChildInRoot,findLayout,safeNestedCanvasCommand,layoutPreset,moveNested};
});