feat: v5.14.0 Synced blocks - block created once, edited everywhere
- Table synced_blocks (source of truth) + page_synced_blocks (references) - Migration 15 + service app/services/synced_blocks.py - API endpoints: CRUD synced blocks, add/remove page references - Editor: /synced slash command, synced block rendering with badge - Realtime: _propagate_synced broadcasts updates to all referencing rooms - Export: synced blocks resolved in Markdown/HTML/PDF - 18 tests in tests/test_v514_synced_blocks.py Co-authored-by: FlowDeck <[email protected]>
This commit is contained in:
@@ -22,6 +22,7 @@ window.__fdEditorScriptsLoaded = true;
|
||||
{id:'audio',name:'Audio',icon:'🎵'},
|
||||
]},
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]},
|
||||
{name:'SYNCED',items:[{id:'synced',name:'Synced block',icon:'🔗'}]},
|
||||
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
|
||||
|
||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
||||
@@ -567,6 +568,16 @@ window.__fdEditorScriptsLoaded = true;
|
||||
}
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='synced'){
|
||||
var syncedTitle=b._synced_title||'Synced block';
|
||||
var syncedContent=b._synced_content||[];
|
||||
var sid=b.synced_id||'';
|
||||
var inner=syncedContent.map(function(ch){return'<div class="block-content" style="padding-left:16px;border-left:2px solid var(--accent);margin:4px 0;">'+mdEsc(typeof ch==='object'?ch.content||'':ch)+'</div>';}).join('');
|
||||
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid var(--accent);background:var(--synced-bg,var(--bg-secondary))">
|
||||
<div class="synced-badge" style="font-size:11px;color:var(--accent);padding:4px 8px;font-weight:600;">🔗 Synced — ${esc(syncedTitle)}</div>
|
||||
<div class="synced-content">${inner||'<div style="color:var(--text-dim);padding:8px;">Loading synced content…</div>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...');
|
||||
let inner='';
|
||||
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
|
||||
@@ -1958,8 +1969,16 @@ applyAIBlocks(text){
|
||||
},
|
||||
applySlash(id){
|
||||
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
if(id==='synced'){
|
||||
const block=this.blocks[idx];this.sync();this.pushHistory();
|
||||
block.type='synced';block.synced_id=0;block._synced_content=[];block._synced_title='';
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
// Open synced block picker
|
||||
this._pickSyncedBlock(idx);
|
||||
return;
|
||||
}
|
||||
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
|
||||
if(id==='duplicate'){this.duplicateBlock(idx);return;}
|
||||
if(id==='delete'){this.removeBlock(idx);return;}
|
||||
if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;}
|
||||
@@ -2016,8 +2035,30 @@ applyAIBlocks(text){
|
||||
_addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);},
|
||||
_removeColumn(parentId,childId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children=b.children.filter(function(c){return c.id!==childId;});if(b.children.length===0)b.children=[{id:genId(),type:'paragraph',content:''}];this.render();this.dirty=true;this.autoSave();_rtSync();},
|
||||
_toggleCalloutIcon(bid){const picker=document.getElementById('callout-picker-'+bid);if(picker)picker.style.display=picker.style.display==='none'?'flex':'none';},
|
||||
_setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
_setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}},
|
||||
// ── v5.14.0: Synced blocks picker ──
|
||||
async _pickSyncedBlock(idx){
|
||||
const block=this.blocks[idx];if(!block)return;
|
||||
try{
|
||||
const r=await fetch('/api/synced-blocks');const d=await r.json();
|
||||
const items=d.synced_blocks||[];
|
||||
if(!items.length){this.showToast('No synced blocks yet. Create one first in Settings → Synced Blocks.');return;}
|
||||
const names=items.map(function(s){return (s.title||'Untitled')+' (id:'+s.id+')';});
|
||||
const choice=window.prompt('Choose a synced block:\n\n'+names.join('\n'));
|
||||
if(!choice)return;
|
||||
const match=choice.match(/id:(\d+)/);
|
||||
if(!match)return;
|
||||
const sid=parseInt(match[1]);
|
||||
if(!sid){this.showToast('Invalid synced block','error');return;}
|
||||
const sr=await fetch('/api/synced-blocks/'+sid);const sd=await sr.json();
|
||||
if(!sd||!sd.content){this.showToast('Synced block not found','error');return;}
|
||||
block.synced_id=sid;
|
||||
block._synced_content=sd.content||[];
|
||||
block._synced_title=sd.title||'';
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
}catch(e){this.showToast('Failed to load synced blocks','error');}
|
||||
},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
|
||||
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
|
||||
Reference in New Issue
Block a user