feat(wiki,templates): v5.11.0 wiki-links & mentions + v5.12.0 templates & page lock (release 5.12.0)
FlowDeck CI / lint (push) Successful in 50s
FlowDeck CI / test (push) Successful in 5m41s
FlowDeck CI / docker (push) Successful in 45s

v5.11.0 Wiki-links & mentions de page :
- tokens [[fdpage:ID]] / [[fddate:ISO]] dans le texte des blocs,
  service app/services/wiki_links.py (labels, rendu HTML, extraction)
- taper [[ ouvre le picker de pages (recherche floue, clavier) ;
  le menu @ gagne les sections Pages et Date (today/tomorrow/YYYY-MM-DD)
- chips atomiques contenteditable=false relues en tokens par gtTok()
  (autosave/drag/undo preservent les liens) ; renommage propage via
  GET /board/api/wiki/titles ; backlinks reconnaissent les tokens ;
  page publique rend les chips (echopee)

v5.12.0 Templates & verrouillage :
- template picker global sur + New page : 5 built-in
  (app/services/block_templates.py) + templates perso
  (table page_global_templates, migration 13)
- POST /board/api/page-templates (save current page) + /{id}/use
  (instantiate, id 0 = built-in par cle)
- page lock : POST /api/pages/{id}/lock, garde _ensure_page_editable
  -> 423 en ecriture pour les non-privileged, deblocage par
  locked_by ou admin seulement (403 sinon), banniere + read-only UI
- full-width / small text par page (pages.full_width/font_small,
  POST /api/pages/{id}/options, classes CSS)
- migration 13 : is_locked, locked_by, full_width, font_small,
  page_global_templates

Tests : tests/test_v511_v512_wiki_templates.py (15) ; suite complete
538 verte ; ruff OK ; node --check des templates JS OK.
This commit is contained in:
2026-09-14 06:38:09 -04:00
parent d4adf89db5
commit 9dfc38706c
14 changed files with 1439 additions and 95 deletions
+298 -36
View File
@@ -27,11 +27,31 @@ window.__fdEditorScriptsLoaded = true;
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');}
// v5.11.0: read a contenteditable block back to plain text, converting
// wiki chip nodes (contenteditable=false spans) back into their raw
// [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens.
function gtTok(e){
if(!e) return '';
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e);
let out='';
(function walk(node){
for(const n of node.childNodes){
if(n.nodeType===3){out+=n.nodeValue;continue;}
if(n.nodeType===1){
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
if(n.tagName==='BR'){out+='\n';continue;}
walk(n);
}
}
})(e);
return out;
}
window.gtTok=gtTok;
function splitCaret(e){
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
sel.insertNode(mark);
const full=gt(e)||'';
const full=gtTok(e)||'';
const parts=full.split(MK);
mark.remove(); e.normalize();
return {before:parts[0]||'',after:(parts.length>1?parts[1]:'')||'',content:full.split(MK).join('')};
@@ -48,9 +68,45 @@ window.__fdEditorScriptsLoaded = true;
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){
return '<a class="fd-wiki-chip" contenteditable="false" href="/pages/'+pid+'" data-token="'+m+'" data-pid="'+pid+'" title="Linked page"><span class="fd-wiki-label">…</span></a>';})
.replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){
return '<span class="fd-wiki-date" contenteditable="false" data-token="'+m+'">'+fdDateLabel(iso)+'</span>';})
.replace(/\[([^\]]+)\]\((https?:[^\)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\n/g,'<br>');
}
// v5.11.0: labels of [[fdpage:ID]] chips are resolved from the live DB
// (rename propagation — tokens store only the id).
function fdDateLabel(iso){
try{
const p=iso.split('-'); const d=new Date(+p[0],+p[1]-1,+p[2]);
const DOW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return DOW[d.getDay()]+' '+(+p[2])+' '+MON[+p[1]-1]+' '+p[0];
}catch(e){return iso;}
}
function _wikiResolve(root){
const ct=root||document.getElementById('_blocksCt'); if(!ct)return;
const pids={};
ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){
const lab=a.querySelector('.fd-wiki-label');
if(!lab||lab.getAttribute('data-done'))return;
lab.setAttribute('data-done','1');
pids[a.dataset.pid]=true;
});
const ids=Object.keys(pids); if(!ids.length)return;
fetch('/board/api/wiki/titles?ids='+encodeURIComponent(ids.join(',')),{credentials:'same-origin'})
.then(function(r){return r.json();})
.then(function(d){
const titles=d.titles||{};
ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){
const t=titles[a.dataset.pid];
const lab=a.querySelector('.fd-wiki-label');
if(lab) lab.textContent = t||'Deleted page';
});
}).catch(function(){});
}
window._wikiResolve=_wikiResolve;
const CALLOUT_ICONS=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍'];
@@ -297,6 +353,64 @@ window.__fdEditorScriptsLoaded = true;
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
// ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════
const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null,
mk(){ if(this._root)return this._root;
const d=document.createElement('div'); d.id='_wikiMenu';
d.style.cssText='position:fixed;z-index:1250;display:none;width:340px;max-height:320px;flex-direction:column;background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden';
const list=document.createElement('div'); list.style.cssText='overflow-y:auto;flex:1;min-height:60px;max-height:250px;padding:6px';
const inp=document.createElement('input'); inp.type='text'; inp.placeholder='Search pages…';
inp.style.cssText='width:100%;padding:9px 12px;background:var(--bg-secondary);border:none;border-top:1px solid var(--border);color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
d.appendChild(list); d.appendChild(inp); document.body.appendChild(d);
this._root=d; this._li=list; this._in=inp;
inp.oninput=()=>{clearTimeout(this._t);this._t=setTimeout(()=>this.load(),180);};
list.addEventListener('click',ev=>{const it=ev.target.closest('.wm-item');if(it){ev.preventDefault();WM.sel(parseInt(it.dataset.wpid,10));}});
document.addEventListener('mousedown',ev=>{if(!d.contains(ev.target))WM.close();});
document.addEventListener('keydown',function(ev){
if(!WM._o)return;
if(ev.metaKey||ev.ctrlKey||ev.altKey)return;
if(ev.key==='Escape'){ev.preventDefault();ev.stopPropagation();WM.close();}
else if(ev.key==='ArrowDown'){ev.preventDefault();ev.stopPropagation();WM._mv(1);}
else if(ev.key==='ArrowUp'){ev.preventDefault();ev.stopPropagation();WM._mv(-1);}
else if(ev.key==='Enter'){ev.preventDefault();ev.stopPropagation();const c=WM._it[WM._s];if(c)WM.sel(c.id);}
},true);
return d; },
open(i,el){ this.mk(); const r=el.getBoundingClientRect();
this._root.style.display='flex';
this._root.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
this._root.style.left=Math.min(r.left,window.innerWidth-350)+'px';
this._idx=i; this._s=0; this._in.value=''; this._o=true;
this.load(); setTimeout(()=>this._in.focus(),10); },
close(){ if(this._root)this._root.style.display='none'; this._o=false; },
load(){ const self=this; const q=(this._in.value||'').toLowerCase();
fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'})
.then(r=>r.json()).then(d=>{
if(!self._o)return;
self._it=d.pages||[]; self._s=0;
let h='<div style="padding:6px 10px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase">Pages</div>';
self._it.forEach(function(p,idx){
h+='<div class="wm-item" data-wpid="'+p.id+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;'+(idx===0?'background:rgba(35,131,226,.15)':'')+'">'
+'<span style="width:18px;text-align:center;flex-shrink:0">'+esc(p.icon||'📄')+'</span>'
+'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(p.title)+'</span>'
+'<span style="font-size:11px;color:var(--text-dim)">'+esc(p.workspace||'')+'</span></div>';
});
if(!self._it.length)h+='<div style="padding:12px;color:var(--text-dim);font-size:13px">No page found</div>';
self._li.innerHTML=h;
}).catch(()=>{}); },
_mv(dir){ if(!this._it.length)return;
this._s=(this._s+dir+this._it.length)%this._it.length;
this._li.querySelectorAll('.wm-item').forEach((el,i)=>{el.style.background=i===this._s?'rgba(35,131,226,.15)':'';});
const cur=this._li.querySelectorAll('.wm-item')[this._s]; if(cur&&cur.scrollIntoView)cur.scrollIntoView({block:'nearest'}); },
sel(pid){ const E=window.E; if(!E||this._idx<0)return;
const b=E.blocks[this._idx]; if(!b)return;
// Drop the typed "[[" from the live DOM text, append the token chip.
const el=E.getEl(b.id); let text=(el?gtTok(el):b.content)||'';
if(text.endsWith('[[')) text=text.slice(0,-2);
else text=text.replace(/\[\[[^\]]*$/,'');
E._commitBlockText(b, text+'[[fdpage:'+pid+']] ');
this.close(); },
get isOpen(){return this._o;}};
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
// → framed result → "Insert below" → applyAIBlocks.
@@ -907,6 +1021,9 @@ window.__fdEditorScriptsLoaded = true;
iconValue:'',
// ── Import ──
importOpen:false,importText:'',importFile:null,
// ── v5.11.0 wiki-links & v5.12.0 page options ──
isLocked:false,lockedBy:null,canEdit:true,fullWidth:false,fontSmall:false,
tplSaving:false,
// ── DB Templates ──
dbTplOpen:false,dbTplLoading:false,dbTemplates:[],
// ── Version history ──
@@ -933,12 +1050,16 @@ window.__fdEditorScriptsLoaded = true;
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||'';
if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);}
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
else if(raw&&raw.trim())this.blocks=this.md2b(raw);
// v5.12.0: page lock + layout options
this.isLocked=!!data.is_locked;this.lockedBy=data.locked_by||null;
this.canEdit=data.can_edit!==false;this.fullWidth=!!data.full_width;this.fontSmall=!!data.font_small;}catch(e){}}
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
this.loadInlineDBs();
window.E=this;
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
SM.init();_tblInitDelegate();this.render();
this._applyLockMode();this._applyLayout();_wikiResolve();
const tEl=document.getElementById('_titleEl');
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
@@ -954,6 +1075,67 @@ window.__fdEditorScriptsLoaded = true;
if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);}
},100);
},
// ── v5.12.0: lock + layout helpers ──
_applyLockMode(){
document.body.classList.toggle('page-locked',!!this.isLocked);
let banner=document.getElementById('_lockBanner');
if(this.isLocked){
if(!banner){
banner=document.createElement('div');banner.id='_lockBanner';
banner.className='fd-lock-banner';
banner.innerHTML='<span>🔒</span><span>This page is locked (read-only)</span>'
+'<button type="button" class="btn-sm" id="_lockUnlockBtn" style="margin-left:auto;font-size:12px;cursor:pointer;">Unlock</button>';
const wrap=document.querySelector('.page-editor-wrapper');
if(wrap)wrap.insertBefore(banner,wrap.firstChild);
}
const ub=banner.querySelector('#_lockUnlockBtn');
if(ub)ub.style.display=this.canEdit?'':'none';
}else if(banner){banner.remove();}
const ct=document.getElementById('_blocksCt');
if(ct){ct.querySelectorAll('[data-bid]').forEach(el=>el.setAttribute('contenteditable',this.canEdit?'true':'false'));}
const t=document.getElementById('_titleEl');
if(t)t.setAttribute('contenteditable',this.canEdit?'true':'false');
},
_applyLayout(){
const wrap=document.querySelector('.page-editor-wrapper');
if(!wrap)return;
wrap.classList.toggle('full-width',!!this.fullWidth);
wrap.classList.toggle('small-text',!!this.fontSmall);
},
async toggleLock(){
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
const next=!this.isLocked;
try{
const r=await fetch('/board/api/pages/'+this.pid+'/lock',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({locked:next})});
if(!r.ok){const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Lock failed','error');return;}
this.isLocked=next;this._applyLockMode();
this.showToast(next?'🔒 Page locked':'🔓 Page unlocked');
}catch(e){this.showToast('Lock failed','error');}
},
async setPageOption(key,val){
try{
const body={};body[key]=val;
const r=await fetch('/board/api/pages/'+this.pid+'/options',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify(body)});
if(r.ok){this[key]=val;this._applyLayout();}
}catch(e){}
},
async saveAsTemplate(){
if(!this.pageTitle||!(this.pageTitle||'').trim()){this.showToast('Give the page a title first','error');return;}
const name=(this.pageTitle||'').trim()+' template';
this.tplSaving=true;this.sync();
const blocks=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','children','rows'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
try{
const r=await fetch('/board/api/page-templates',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({name:name,blocks:blocks})});
if(r.ok){this.moreOpen=false;this.showToast('📑 Saved as template: '+name);}
else{const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Save failed','error');}
}catch(e){this.showToast('Save failed','error');}
this.tplSaving=false;
},
fileData:null,
fileUrl:'',
async loadFileContent(data){
@@ -979,6 +1161,16 @@ window.__fdEditorScriptsLoaded = true;
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
},
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;},
// v5.11.0: rewrite one block's text (e.g. after inserting a wiki token)
// and re-render in place, caret at end.
_commitBlockText(b,text){
this.sync();this.pushHistory();
b.content=text;
this.dirty=true;this.autoSave();this.render();_rtSync();
const el=this.getEl(b.id);
if(el){el.focus();const r=document.createRange();r.selectNodeContents(el);r.collapse(false);const s=window.getSelection();s.removeAllRanges();s.addRange(r);}
if(window.__fdRT&&window.__fdRT.syncNow)window.__fdRT.syncNow();
},
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
@@ -1012,9 +1204,9 @@ window.__fdEditorScriptsLoaded = true;
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtTok(el)||'';else if(el)b.content=gtTok(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
}},
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
// Render math blocks with KaTeX
@@ -1027,8 +1219,15 @@ window.__fdEditorScriptsLoaded = true;
setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50);
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
_selApply();
},
_selApply();
var self2=this;
setTimeout(function(){
if(window._wikiResolve)_wikiResolve();
if(!self2.canEdit)self2._applyLockMode();
const ub=document.getElementById('_lockUnlockBtn');
if(ub&&!ub._wired){ub._wired=true;ub.onclick=function(){window.E.toggleLock();};}
},30);
},
_renderKatex(el,tex){
if(!el)return;if(!tex||!tex.trim()){el.innerHTML='<span style="color:var(--text-dim);font-style:italic;">Empty math block</span>';return;}
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
@@ -1693,7 +1892,8 @@ applyAIBlocks(text){
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
}
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);}
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2);
if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);}
if(e.key===' '){
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
e.preventDefault(); window.AIC.open(idx); return;
@@ -1704,7 +1904,7 @@ applyAIBlocks(text){
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
e.preventDefault();
const sp=splitCaret(el);
const before=sp?sp.before:gt(el)||'',after=sp?sp.after:'';
const before=sp?sp.before:gtTok(el)||'',after=sp?sp.after:'';
if(!before.trim()&&!after.trim()){
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
this.sync();this.pushHistory();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();_rtSync();
@@ -1859,53 +2059,115 @@ applyAIBlocks(text){
btn.style.left=x+'px';btn.style.top=y+'px';
},
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
// ── @mention autocomplete ──
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionEl:null,
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
openMention(el){
const s=window.getSelection();if(!s.rangeCount)return;
const text=el.textContent||'';const pos=s.getRangeAt(0).startOffset;
const before=text.substring(0,pos);
const range=s.getRangeAt(0).cloneRange();
range.selectNodeContents(el);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
const before=range.toString();
const m=before.match(/@([\w\-\.]*)$/);
if(!m)return;
if(!m){return;}
this.mentionQuery=m[1];this.mentionEl=el;
const r=s.getRangeAt(0).getBoundingClientRect();
const menu=document.getElementById('_mentionMenu');
menu.style.display='block';
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-280)?r.top-284:r.bottom+4)+'px';
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-300)?r.top-304:r.bottom+4)+'px';
menu.style.left=Math.min(r.left,window.innerWidth-260)+'px';
this.mentionOpen=true;this.loadMentionUsers();
this.mentionOpen=true;this.loadMentionResults();
},
async loadMentionUsers(){
async loadMentionResults(){
const q=this.mentionQuery||'';
try{const r=await fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'});const d=await r.json();this.mentionResults=d.users||[];this.renderMentionMenu();}catch(e){this.mentionResults=[];}
const self=this;
const [u,p]=await Promise.all([
fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{users:[]}).catch(()=>({users:[]})),
fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{pages:[]}).catch(()=>({pages:[]}))
]);
if(!this.mentionOpen)return;
this.mentionResults=u.users||[];this.mentionPages=p.pages||[];
this.renderMentionMenu();
},
_dateSuggestions(q){
const out=[];const lq=(q||'').toLowerCase();
const iso=s=>s.toISOString().slice(0,10);
const now=new Date();
if(!lq||'today'.startsWith(lq))out.push({label:'Today',iso:iso(now)});
if(!lq||'tomorrow'.startsWith(lq))out.push({label:'Tomorrow',iso:iso(new Date(now.getTime()+864e5))});
if(!lq||'yesterday'.startsWith(lq))out.push({label:'Yesterday',iso:iso(new Date(now.getTime()-864e5))});
const m=lq.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if(m)out.push({label:'Set date',iso:m[1]+'-'+m[2].padStart(2,'0')+'-'+m[3].padStart(2,'0')});
return out;
},
renderMentionMenu(){
const menu=document.getElementById('_mentionMenu');if(!menu)return;
let h='';const self=this;
(this.mentionResults||[]).forEach(function(u,idx){
h+='<div class="mention-item" data-login="'+u.login+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;">'
h+='<div class="mention-group-label">People</div>';
(this.mentionResults||[]).slice(0,6).forEach(function(u){
h+='<div class="mention-item" data-mkind="user" data-mval="'+esc(u.login)+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;overflow:hidden;">'
+((u.avatar_url)?'<img src="'+u.avatar_url+'" style="width:24px;height:24px;border-radius:50%;object-fit:cover;">':((u.full_name||u.login||'?').charAt(0).toUpperCase()))
+'</div><div><div style="font-weight:500">'+u.login+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+(u.full_name||'')+'</div></div></div>';
+'</div><div><div style="font-weight:500">'+esc(u.login)+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+esc(u.full_name||'')+'</div></div></div>';
});
if(!h)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No users found</div>';
if((this.mentionPages||[]).length){
h+='<div class="mention-group-label">Pages</div>';
this.mentionPages.slice(0,6).forEach(function(p){
h+='<div class="mention-item" data-mkind="page" data-mval="'+p.id+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<span style="width:24px;text-align:center;flex-shrink:0">'+esc(p.icon||'📄')+'</span>'
+'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(p.title)+'</span></div>';
});
}
const ds=this._dateSuggestions(this.mentionQuery);
if(ds.length){
h+='<div class="mention-group-label">Date</div>';
ds.forEach(function(d){
h+='<div class="mention-item" data-mkind="date" data-mval="'+esc(d.iso)+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<span style="width:24px;text-align:center;flex-shrink:0">📅</span>'
+'<span style="flex:1">'+esc(d.label)+'</span><span style="font-size:11px;color:var(--text-dim)">'+esc(d.iso)+'</span></div>';
});
}
if(this.mentionResults.length===0&&this.mentionPages.length===0&&ds.length===0)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No matches</div>';
menu.innerHTML=h;
menu.querySelectorAll('.mention-item').forEach(function(it){
it.addEventListener('click',function(){self.insertMention(it.getAttribute('data-login'));});
it.addEventListener('click',function(){
const kind=it.getAttribute('data-mkind');const val=it.getAttribute('data-mval');
if(kind==='user')self.insertMention(val);
else if(kind==='page')self.insertMentionToken('[[fdpage:'+val+']] ');
else if(kind==='date')self.insertMentionToken('[[fddate:'+val+']] ');
});
});
},
// Shared non-destructive insertion: replaces the typed "@query" right
// before the caret inside the token-reconstructed text (chips kept).
_replaceAtMention(replacement){
const el=this.mentionEl;if(!el)return null;
const idx=this.getIdx(el.dataset.bid);if(idx<0)return null;
const b=this.blocks[idx];
const text=gtTok(el)||'';
// robust: compute caret by walking with a marker
let caretPos=text.length;
try{
const MK='\uF000AT';const mark=document.createElement('span');mark.textContent=MK;
const live=s.getRangeAt(0).cloneRange();live.collapse(true);live.insertNode(mark);
const full=gtTok(el)||'';const at=full.indexOf(MK);
if(at>=0)caretPos=at;
mark.remove();el.normalize();
}catch(e){}
const before=text.slice(0,caretPos);
const m=before.match(/@([\w\-\.]*)$/);
if(!m)return null;
const start=caretPos-m[0].length;
const newText=text.slice(0,start)+replacement+text.slice(caretPos);
this._commitBlockText(b,newText);
return true;
},
insertMentionToken(token){
if(this._replaceAtMention(token)){this.closeMention();this.dirty=true;this.autoSave();}
},
insertMention(login){
const el=this.mentionEl;if(!el)return;
const s=window.getSelection();if(s.rangeCount){s.deleteFromDocument();}
const text=el.textContent||'';const pos=s.rangeCount? (()=>{const r=s.getRangeAt(0);return r.startOffset;} )():text.length;
const before=text.substring(0,pos);
const at=before.lastIndexOf('@');
const full=before.substring(0,at)+'@'+login+' ';
const after=text.substring(pos);
el.textContent=full+after;
const ns=window.getSelection();const nr=document.createRange();nr.selectNodeContents(el);nr.collapse(false);ns.removeAllRanges();ns.addRange(nr);
this.closeMention();this.dirty=true;this.autoSave();
this.notifyNewMentions(full);
if(this._replaceAtMention('@'+login+' ')){
this.closeMention();this.dirty=true;this.autoSave();
this.notifyNewMentions('@'+login);
}
},
closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;},
notifyNewMentions(text){