feat: Notion-style block editor v6 + sidebar refactoring
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Has been skipped

Block editor:
- Vanilla JS rendering (renderBlock) — zero Alpine x-for on blocks
- 15 block types with Notion placeholders (Heading 1-4, Toggle list, Empty quote, etc.)
- Event delegation keydown handler on container
- sync() before every render() — content preserved across Enter/Backspace/type changes
- Slash menu with event delegation, single innerHTML set, window.E for applySlash
- Markdown shortcuts use idx (not bid) to avoid stale references after render
- data-bid only on contentEditable (not .block-wrapper) — fixes '+' artifact
- .empty class + outline:none on block-content

Sidebar:
- Notion-style collapsible sections (Recents/Private/Shared/Notion apps)
- Workspace header with chevron, Search tab, Home tab
- Sticky footer with '+ New page' button
- sectionsOpen/toggleSection/toggleWorkspaceMenu/openQuickFind in appState
- CSS: uppercase section headers, 240px width, collapsed 56px mode, border-right
This commit is contained in:
2026-07-09 15:47:43 -04:00
parent 8aa1a703b5
commit a585587e4c
3 changed files with 247 additions and 378 deletions
+89 -105
View File
@@ -48,140 +48,124 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
<!-- ═══════════ SIDEBAR ═══════════ --> <!-- ═══════════ SIDEBAR Notion-style ═══════════ -->
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar" <aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar"
x-init="$nextTick(() => initTreeSortable())"> x-data x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
<!-- Workspace header --> <!-- Workspace header -->
<div class="sidebar-workspace"> <div class="sidebar-workspace" @click="toggleWorkspaceMenu">
<div class="workspace-info"> <div class="workspace-info">
<div class="workspace-avatar">{{ workspaceInitial }}</div> <div class="workspace-avatar">{{ workspaceInitial }}</div>
<span class="workspace-name">{{ workspaceName }}</span> <span class="workspace-name">{{ workspaceName }}</span>
</div> </div>
<div class="sidebar-actions"> <span class="workspace-chevron">▾</span>
<button class="sidebar-action-btn" @click="sidebarCollapsed = !sidebarCollapsed" title="Toggle sidebar"> </div>
«
</button> <!-- Search -->
<div class="sidebar-nav">
<div class="sidebar-nav-item" @click="openQuickFind">
<span class="nav-icon">🔍</span> Search
</div> </div>
</div> </div>
<!-- Quick actions --> <!-- Settings -->
<div class="sidebar-nav"> <div class="sidebar-nav">
<a href="/" class="sidebar-nav-item" :class="{ active: currentView === 'home' }"> <a href="/" class="sidebar-nav-item" :class="{ active: currentView === 'home' }">
<span class="nav-icon">🏠</span> Home <span class="nav-icon">🏠</span> Home
</a> </a>
</div> </div>
<div class="sidebar-nav" style="gap: 2px; padding-right: 6px;">
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">💬</button>
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🎤</button>
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">📥</button>
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🔍</button>
</div>
<!-- Recents --> <!-- Recents -->
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-header"> <div class="sidebar-section-header" @click="toggleSection('recents')">
<div class="sidebar-section-title"> <div class="sidebar-section-title">
<span class="chevron open">▶</span> Recents <span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
<span>Recents</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="Open in Library" @click="openLibrary('Recents')">📚</button> <button class="section-action-btn" title="Add page" @click.stop="newPage('Recents')">+</button>
<button class="section-action-btn" title="Add page" @click="newPage('Recents')">+</button>
</div> </div>
</div> </div>
<ul class="sidebar-items" data-section="recents"> <div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
{% for page in recent_pages %} <ul class="sidebar-items" data-section="recents">
{{ render_tree_item(page) }} {% for page in recent_pages %}
{% endfor %} {{ render_tree_item(page) }}
{% if not recent_pages %} {% endfor %}
<li class="sidebar-item"> {% if not recent_pages %}
<span class="tree-toggle-placeholder"></span> <li class="sidebar-item">
<span class="page-icon">📄</span> <span class="tree-toggle-placeholder"></span>
<span class="page-name text-dim">No recent pages</span> <span class="page-icon">📄</span>
</li> <span class="page-name text-dim">No recent pages</span>
{% endif %} </li>
</ul> {% endif %}
</ul>
</div>
</div> </div>
<!-- Favorites: Private --> <!-- Favorites section is optionnel ici -->
<!-- Private -->
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-header"> <div class="sidebar-section-header" @click="toggleSection('private')">
<div class="sidebar-section-title"> <div class="sidebar-section-title">
<span class="chevron open">▶</span> Private <span class="chevron" :class="{ open: sectionsOpen.private }" x-text="sectionsOpen.private ? '▼' : '▶'"></span>
<span>Private</span>
</div> </div>
<div class="sidebar-section-actions"> <div class="sidebar-section-actions">
<button class="section-action-btn" title="Open in Library" @click="openLibrary('Private')">📚</button> <button class="section-action-btn" title="Add page" @click.stop="newPage('Private')">+</button>
<button class="section-action-btn" title="Add page" @click="newPage('Private')">+</button>
</div> </div>
</div> </div>
<ul class="sidebar-items" data-section="private"> <div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
{% for page in private_pages %} <ul class="sidebar-items" data-section="private">
{{ render_tree_item(page) }} {% for page in private_pages %}
{% endfor %} {{ render_tree_item(page) }}
{% if not private_pages %} {% endfor %}
<li class="sidebar-item"> {% if not private_pages %}
<span class="tree-toggle-placeholder"></span> <li class="sidebar-item">
<span class="page-icon">📄</span> <span class="tree-toggle-placeholder"></span>
<span class="page-name text-dim">No pages yet</span> <span class="page-icon">📄</span>
</li> <span class="page-name text-dim">No pages yet</span>
{% endif %} </li>
</ul> {% endif %}
</div> </ul>
<!-- Notion apps -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Notion apps
</div>
</div> </div>
<ul class="sidebar-items">
<li class="sidebar-item"><span class="page-icon">📅</span><span class="page-name">Notion Calendar</span></li>
<li class="sidebar-item"><span class="page-icon">🖥️</span><span class="page-name">Notion Desktop</span></li>
</ul>
</div>
<!-- Favorites -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Favorites
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Add page" @click="newPage('Favorites')">+</button>
</div>
</div>
<ul class="sidebar-items" data-section="favorites">
{% for page in favorite_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not favorite_pages %}
<li class="sidebar-item">
<span class="tree-toggle-placeholder"></span>
<span class="page-icon">⭐</span>
<span class="page-name text-dim">No favorites yet</span>
</li>
{% endif %}
</ul>
</div> </div>
<!-- Shared --> <!-- Shared -->
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-section-header"> <div class="sidebar-section-header" @click="toggleSection('shared')">
<div class="sidebar-section-title"> <div class="sidebar-section-title">
<span class="chevron">▶</span> Shared <span class="chevron" :class="{ open: sectionsOpen.shared }" x-text="sectionsOpen.shared ? '▼' : '▶'"></span>
<span>Shared</span>
</div> </div>
</div> </div>
<ul class="sidebar-items"> <div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
<li class="sidebar-item"> <ul class="sidebar-items">
<span class="page-icon">👥</span> <li class="sidebar-item">
<span class="page-name text-dim">No shared pages</span> <span class="page-icon">👥</span>
</li> <span class="page-name text-dim">No shared pages</span>
</ul> </li>
</ul>
</div>
</div> </div>
<!-- Library button --> <!-- Notion apps -->
<div class="sidebar-nav" style="margin-top: 8px;"> <div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('apps')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.apps }" x-text="sectionsOpen.apps ? '▼' : '▶'"></span>
<span>Notion apps</span>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.apps" x-transition>
<ul class="sidebar-items">
<li class="sidebar-item"><span class="page-icon">📅</span><span class="page-name">Notion Calendar</span></li>
<li class="sidebar-item"><span class="page-icon">🖥️</span><span class="page-name">Notion Desktop</span></li>
</ul>
</div>
</div>
<!-- Utility links -->
<div class="sidebar-utility">
<a href="/library" class="sidebar-nav-item" @click.prevent="navigateTo('/library')"> <a href="/library" class="sidebar-nav-item" @click.prevent="navigateTo('/library')">
<span class="nav-icon">📚</span> Library <span class="nav-icon">📚</span> Library
</a> </a>
@@ -189,22 +173,16 @@
<!-- Footer --> <!-- Footer -->
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="sidebar-invite" x-show="showInvite" x-transition> <button class="sidebar-footer-btn" @click="newPage('Private')">
<div class="sidebar-invite-title">👥 Invite members</div> <span style="font-size:16px;">+</span> New page
<div class="sidebar-invite-desc">Collaborate with your team.</div>
<button class="sidebar-invite-close" @click="showInvite = false">✕</button>
</div>
<button class="sidebar-new-chat">
<span>💬 New chat</span>
<span class="shortcut">Ctrl+O</span>
</button> </button>
<div style="display: flex; gap: 4px; margin-top: 4px;"> <div style="display: flex; gap: 4px; margin-top: 4px;">
{% if user %} {% if user %}
<a href="/auth/logout" class="sidebar-new-chat" style="flex:1; justify-content:center;">🚪</a> <a href="/auth/logout" class="sidebar-footer-btn" style="flex:1; justify-content:center;">🚪</a>
{% else %} {% else %}
<a href="/auth/login" class="sidebar-new-chat" style="flex:1; justify-content:center;">🔑</a> <a href="/auth/login" class="sidebar-footer-btn" style="flex:1; justify-content:center;">🔑</a>
{% endif %} {% endif %}
<button class="sidebar-new-chat" style="width:36px; justify-content:center;" @click="toggleTheme()">🌓</button> <button class="sidebar-footer-btn" style="width:36px; justify-content:center;" @click="toggleTheme()">🌓</button>
</div> </div>
</div> </div>
</aside> </aside>
@@ -362,6 +340,12 @@
contextActions: {}, contextActions: {},
showNewPageMenu: false, showNewPageMenu: false,
// ── Sections collapsible ──
sectionsOpen: { recents: true, private: true, shared: true, apps: true },
toggleSection(key) { this.sectionsOpen[key] = !this.sectionsOpen[key]; this.sectionsOpen = Object.assign({}, this.sectionsOpen); },
toggleWorkspaceMenu() { this.sidebarCollapsed = !this.sidebarCollapsed; },
openQuickFind() { alert('Quick Find — Ctrl+K'); },
// ── Tree state ── // ── Tree state ──
expandedNodes: {}, expandedNodes: {},
+136 -266
View File
@@ -9,71 +9,16 @@
<div class="page-title-block"> <div class="page-title-block">
<span class="page-icon-emoji">📄</span> <span class="page-icon-emoji">📄</span>
<div class="page-title-input" contenteditable="true" id="_titleEl" <div class="page-title-input" contenteditable="true" id="_titleEl"
@paste.prevent="pastePlain($event)"
@input="dirty=true;autoSave()" @input="dirty=true;autoSave()"
@keydown.enter.prevent="focusBlock()" @keydown.enter.prevent="focusBlock()"
@paste.prevent="pastePlain($event)"
spellcheck="false">{{ page.title }}</div> spellcheck="false">{{ page.title }}</div>
</div> </div>
</div> </div>
<div class="blocks-container" id="_blocksCt" @click="onCtClick($event)"> <div class="blocks-container" id="_blocksCt" @click="onCtClick($event)"></div>
<template x-for="idx in blocks.length" :key="'b'+idx">
<div class="block-wrapper" :data-bid="blocks[idx-1].id"
@mouseenter="hoverBid=blocks[idx-1].id" @mouseleave="hoverBid=null">
<div class="block-handle" :class="{visible:hoverBid===blocks[idx-1].id}">
<svg viewBox="0 0 16 16" width="14" height="14">
<circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/>
<circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/>
<circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/>
</svg>
</div>
<div class="block-actions" :class="{visible:hoverBid===blocks[idx-1].id}">
<button class="block-add-btn" @click="addAfter(idx-1)">+</button>
</div>
<div x-show="blocks[idx-1].type==='paragraph'" class="block-content" :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="Type '/' for commands..." spellcheck="false"></div>
<h1 x-show="blocks[idx-1].type==='heading_1'" class="block-content block-h1" :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="Heading 1" spellcheck="false"></h1>
<h2 x-show="blocks[idx-1].type==='heading_2'" class="block-content block-h2" :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="Heading 2" spellcheck="false"></h2>
<h3 x-show="blocks[idx-1].type==='heading_3'" class="block-content block-h3" :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="Heading 3" spellcheck="false"></h3>
<div x-show="blocks[idx-1].type==='bulleted_list'" class="block-content block-bullet" :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="List" spellcheck="false"></div>
<div x-show="blocks[idx-1].type==='numbered_list'" class="block-content block-numbered" :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="List" spellcheck="false"></div>
<div x-show="blocks[idx-1].type==='to_do'" class="block-content block-todo">
<input type="checkbox" class="todo-checkbox" :checked="blocks[idx-1].checked" @change="blocks[idx-1].checked=$event.target.checked;dirty=true;autoSave()">
<div :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="To-do" spellcheck="false"></div>
</div>
<div x-show="blocks[idx-1].type==='toggle'" class="block-content block-toggle">
<button class="toggle-chevron" :class="{open:blocks[idx-1].expanded}" @click="blocks[idx-1].expanded=!blocks[idx-1].expanded;autoSave()">▶</button>
<div :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="Toggle" spellcheck="false"></div>
</div>
<blockquote x-show="blocks[idx-1].type==='quote'" class="block-content block-quote" :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="Quote" spellcheck="false"></blockquote>
<div x-show="blocks[idx-1].type==='code'" class="block-content block-code">
<div class="code-lang-label" x-text="blocks[idx-1].language||'Plain Text'"></div>
<pre><code :data-bid="blocks[idx-1].id" contenteditable="true" spellcheck="false"></code></pre>
</div>
<hr x-show="blocks[idx-1].type==='divider'" class="block-divider">
<div x-show="blocks[idx-1].type==='callout'" class="block-content block-callout" :style="{background:(blocks[idx-1].style&&blocks[idx-1].style.bgColor)||'var(--blue-bg)'}">
<span class="callout-icon" x-text="blocks[idx-1].icon||'💡'">💡</span>
<div :data-bid="blocks[idx-1].id" contenteditable="true" data-placeholder="Type something..." spellcheck="false"></div>
</div>
<div x-show="blocks[idx-1].type==='image'" class="block-content block-image-wrapper">
<img :src="blocks[idx-1].src" style="max-width:100%;border-radius:4px" x-show="blocks[idx-1].src">
<div class="image-placeholder" x-show="!blocks[idx-1].src" @click="imgInputs[blocks[idx-1].id]?.click()" @drop.prevent="dropImg($event,idx-1)" @dragover.prevent>
<span>🖼 Click or drop image</span>
<input type="file" accept="image/*" style="display:none" :ref="el=>{if(el)imgInputs[blocks[idx-1].id]=el}" @change="uploadImg(idx-1,$event)">
</div>
</div>
</div>
</template>
<div class="blocks-empty" x-show="blocks.length===0" @click="addAt(0)">Press '/' for commands, or just start typing...</div>
</div>
<!-- Slash menu: built in vanilla JS, not Alpine -->
<div id="_slashMenu" class="slash-menu" style="display:none;position:fixed;z-index:999"></div> <div id="_slashMenu" class="slash-menu" style="display:none;position:fixed;z-index:999"></div>
<!-- Format Toolbar: Alpine for show/hide only -->
<div class="format-toolbar" x-show="fmt.open" :style="{top:fmt.top+'px',left:fmt.left+'px'}" @mousedown.prevent> <div class="format-toolbar" x-show="fmt.open" :style="{top:fmt.top+'px',left:fmt.left+'px'}" @mousedown.prevent>
<button @click="fmtApply('bold')"><strong>B</strong></button> <button @click="fmtApply('bold')"><strong>B</strong></button>
<button @click="fmtApply('italic')"><em>I</em></button> <button @click="fmtApply('italic')"><em>I</em></button>
@@ -94,132 +39,78 @@
{% block scripts %} {% block scripts %}
<script> <script>
// ═══════════════════════════════════════════ const CMDS=[{name:'BASIC',items:[
// FlowDeck Block Editor — Notion Clone v4 {id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
// Slash menu: pure vanilla JS (zero Alpine) {id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
// Blocks: Alpine x-for + vanilla JS keydown {id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
// ═══════════════════════════════════════════ {id:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼'}]}];
let _bid=0; let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
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 ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// Slash commands data // Slash menu — event delegation on list, single innerHTML set
const SLASH_GROUPS=[ const SM={_o:false,_i:-1,_s:0,_q:'',_it:[],
{name:'BASIC',items:[ init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML='';
{id:'paragraph',name:'Text',icon:'¶'}, const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...';
{id:'heading_1',name:'Heading 1',icon:'H1'}, inp.style.cssText='width:100%;padding:6px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
{id:'heading_2',name:'Heading 2',icon:'H2'}, inp.oninput=()=>SM.flt();inp.onkeydown=ev=>SM._kd(ev);
{id:'heading_3',name:'Heading 3',icon:'H3'}, const hdr=document.createElement('div');hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';hdr.appendChild(inp);
{id:'bulleted_list',name:'Bulleted list',icon:'•'}, const list=document.createElement('div');list.style.cssText='overflow-y:auto;max-height:340px';
{id:'numbered_list',name:'Numbered list',icon:'1.'}, // Event delegation: single click handler for all items
{id:'to_do',name:'To-do',icon:'☐'}, list.addEventListener('click',ev=>{const it=ev.target.closest('.slash-item');if(it){ev.preventDefault();SM.sel(it.dataset.sid);}});
{id:'toggle',name:'Toggle',icon:'▶'}, e.appendChild(hdr);e.appendChild(list);
{id:'quote',name:'Quote',icon:'❝'}, SM._e=e;SM._in=inp;SM._li=list;
{id:'callout',name:'Callout',icon:'💡'}, document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
{id:'divider',name:'Divider',icon:'—'},
{id:'code',name:'Code block',icon:'<>'},
]},
{name:'MEDIA',items:[
{id:'image',name:'Image',icon:'🖼'},
]},
];
// ── Slash Menu (100% vanilla JS, no Alpine) ──
const SlashMenu={
_open:false,_idx:-1,_sel:0,_q:'',_items:[],
_el:null,_inp:null,_list:null,
init(){
this._el=document.getElementById('_slashMenu');
if(!this._el)return;
this._inp=document.createElement('input');
this._inp.type='text';this._inp.placeholder='Filter...';
this._inp.style.cssText='width:100%;padding:6px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
this._inp.oninput=()=>this.filter();
this._inp.onkeydown=e=>this._onKey(e);
const hdr=document.createElement('div');
hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';
hdr.appendChild(this._inp);
this._list=document.createElement('div');
this._list.style.cssText='overflow-y:auto;max-height:340px';
this._el.appendChild(hdr);
this._el.appendChild(this._list);
document.addEventListener('mousedown',e=>{if(!this._el.contains(e.target))this.close();});
}, },
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
open(idx,el){ const r=el.getBoundingClientRect();SM._e.style.display='block';
if(!this._el)this.init(); SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
this._idx=idx;this._q='';this._sel=0;this._inp.value=''; SM._e.style.left=Math.min(r.left,window.innerWidth-324)+'px';
this.filter(); setTimeout(()=>SM._in.focus(),10);SM._o=true;},
const r=el.getBoundingClientRect(); close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');},
this._el.style.display='block'; flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
const mh=this._el.offsetHeight||400; for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
this._el.style.top=Math.max(0,(r.bottom+4+mh>window.innerHeight)?r.top-mh-4:r.bottom+4)+'px'; h+=`<div style="padding:6px 12px 4px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px">${g.name}</div>`;
this._el.style.left=Math.min(r.left,window.innerWidth-330)+'px'; items.forEach(c=>{SM._it.push(c);
this._inp.focus();this._open=true; h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:14px;color:var(--text-primary);cursor:pointer">
},
close(){
this._el.style.display='none';this._open=false;
this._inp.value='';this._q='';
},
filter(){
const q=(this._inp.value||'').toLowerCase();this._q=q;
let html='';
for(const g of SLASH_GROUPS){
let items=g.items;
if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));
if(!items.length)continue;
html+=`<div style="padding:6px 12px 4px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px">${g.name}</div>`;
items.forEach((c,i)=>{
const sel=(this._items.length===this._sel);
if(sel)this._selItem=c.id;
this._items.push(c);
html+=`<div class="slash-item${sel?' selected':''}" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:14px;color:var(--text-primary);cursor:pointer;transition:background .1s"
onmousedown="event.preventDefault()" onclick="SlashMenu.select('${c.id}')" onmouseenter="this.classList.add('selected')" onmouseleave="this.classList.remove('selected')">
<span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span> <span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span style="flex:1;font-weight:500">${c.name}</span> <span style="flex:1;font-weight:500">${c.name}</span></div>`;});}
</div>`; SM._li.innerHTML=h;},
}); _kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
} else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
this._list.innerHTML=html; else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
if(this._items.length)this._selItem=this._items[0].id; else if(e.key==='Escape'){e.preventDefault();SM.close();}},
}, _rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});},
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
_onKey(e){ function renderBlock(b,idx){
if(e.key==='ArrowDown'){e.preventDefault();this._sel=Math.min(this._sel+1,this._items.length-1);this._renderSel();} if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
else if(e.key==='ArrowUp'){e.preventDefault();this._sel=Math.max(this._sel-1,0);this._renderSel();} 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...':'Press \'/\' for commands...';
else if(e.key==='Enter'){e.preventDefault();const c=this._items[this._sel];if(c)this.select(c.id);} let inner='';
else if(e.key==='Escape'){e.preventDefault();this.close();} 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">${esc(b.content)}</div>`;
}, else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
return `<div class="block-wrapper">
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>
<${tag}${style} class="${cls}">${inner}</${tag}>
</div>`;
}
_renderSel(){ window.E=null;
const items=this._list.querySelectorAll('.slash-item');
items.forEach((el,i)=>{el.classList.toggle('selected',i===this._sel);});
},
select(id){
const ed=document.querySelector('.page-editor-wrapper');
if(!ed?.__x)return;
ed.__x.$data.applySlash(id);
this.close();
},
get isOpen(){return this._open;},
};
// ── Cursor helpers ──
function curPos(el){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(el);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
function curEnd(el){const r=document.createRange();r.selectNodeContents(el);r.collapse(false);const s=window.getSelection();s.removeAllRanges();s.addRange(r);}
function curStart(el){const r=document.createRange();r.selectNodeContents(el);r.collapse(true);const s=window.getSelection();s.removeAllRanges();s.addRange(r);}
// ── Editor component ──
function editor(pid,title,fmt,raw){ function editor(pid,title,fmt,raw){
const self={ const self={
pid,pageTitle:title,blocks:[],hoverBid:null, pid,pageTitle:title,blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
dirty:false,saving:false,lastSaved:'',st:null,
imgInputs:{},
fmt:{open:false,top:0,left:0,idx:-1}, fmt:{open:false,top:0,left:0,idx:-1},
init(){ init(){
@@ -227,94 +118,81 @@ function editor(pid,title,fmt,raw){
if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}} 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); else if(raw&&raw.trim())this.blocks=this.md2b(raw);
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')]; if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
window.E=this;SM.init();this.render();
SlashMenu.init(); setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
ct.addEventListener('keydown',e=>this.onKd(e));
// Give Alpine time to render, then setup once
setTimeout(()=>{
const ct=document.getElementById('_blocksCt');
if(!ct)return;
// Fill content
for(const b of this.blocks){
const el=ct.querySelector(`[data-bid="${b.id}"]:not(button):not(input)`);
if(el&&b.type!=='divider')el.textContent=b.content||'';
}
// Focus first block
this.focusBlock();
// KEYDOWN (vanilla JS)
ct.addEventListener('keydown',e=>this.onKd(e,ct));
ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();}); ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();});
// SortableJS if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}});
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();}}); },100);
},400);
}, },
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;return b;}, 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;return b;},
focusFirstBlock(){const b=this.blocks[0];if(b){const el=this.getEl(b.id);if(el){el.focus();curEnd(el);}}}, getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
focusBlock(n){n=n||0;const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();curEnd(el);}else if(n<10){setTimeout(()=>this.focusBlock(n+1),50);}},
onCtClick(e){
if(e.target===document.getElementById('_blocksCt')||e.target.classList.contains('blocks-empty')){
if(!this.blocks.length)this.addAt(0);
else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();curEnd(el);}}
}
},
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]:not(button):not(input)`):null;},
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);}, 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)};},
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}},
addAt(idx,type,content){const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();requestAnimationFrame(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();curEnd(el);}});return b;}, // Sync DOM→data, render, add listeners
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider')continue;const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el)b.content=el.textContent||'';}},
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;
ct.querySelectorAll('.block-wrapper').forEach(w=>{w.onmouseenter=()=>{w.querySelector('.block-handle')?.classList.add('visible');w.querySelector('.block-actions')?.classList.add('visible');};w.onmouseleave=()=>{w.querySelector('.block-handle')?.classList.remove('visible');w.querySelector('.block-actions')?.classList.remove('visible');};});
// Update empty class: browser may insert <br> in contentEditable — strip and apply class
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
},
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
addAfter(idx,type){return this.addAt(idx+1,type);}, addAfter(idx,type){return this.addAt(idx+1,type);},
removeBlock(idx){ removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
if(this.blocks.length<=1){const b=this.blocks[0];b.type='paragraph';b.content='';const el=this.getEl(b.id);if(el)el.textContent='';this.dirty=true;this.autoSave();requestAnimationFrame(()=>{if(el)el.focus();});return;}
this.blocks.splice(idx,1);const fi=Math.max(0,idx-1);this.dirty=true;this.autoSave();
requestAnimationFrame(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();curEnd(el);}});
},
// ── THE KEY HANDLER ── // KEY HANDLER
onKd(e,ct){ onKd(e){
const el=document.activeElement; const ab=this.getActiveBlock();if(!ab)return;
if(!el||!el.hasAttribute('data-bid'))return; const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
const bid=el.dataset.bid,idx=this.getIdx(bid);
if(idx<0)return;
const block=this.blocks[idx];
// Slash menu open if(SM.isOpen){
if(SlashMenu.isOpen){ if(e.key==='Escape'){e.preventDefault();SM.close();return;}
if(e.key==='Escape'){e.preventDefault();SlashMenu.close();return;} if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();SM._in.dispatchEvent(new KeyboardEvent('keydown',{key:e.key,bubbles:true}));return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();SlashMenu._inp.dispatchEvent(new KeyboardEvent('keydown',{key:e.key,bubbles:true}));return;} if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
if(e.key==='Backspace'&&!SlashMenu._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SlashMenu.close();return;}
return; return;
} }
// After content keys, check for slash // Slash detection — use idx, not bid (bid can get stale after render)
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){ 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);}
const b2=bid;setTimeout(()=>{const e2=this.getEl(b2);if(e2&&e2.textContent?.trim()==='/'&&!SlashMenu.isOpen)SlashMenu.open(this.getIdx(b2),e2);},15);
}
// ENTER // ENTER: split block
// Markdown shortcuts on space — use idx
if(e.key===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
// ENTER: split block
if(e.key==='Enter'){ if(e.key==='Enter'){
if(e.shiftKey){this.dirty=true;this.autoSave();return;} if(e.shiftKey){this.dirty=true;this.autoSave();return;}
e.preventDefault(); e.preventDefault();
const text=el.textContent||'',pos=curPos(el),before=text.substring(0,pos),after=text.substring(pos); const text=el.textContent||'',pos=cp(el),before=text.substring(0,pos),after=text.substring(pos);
if(!before.trim()&&!after.trim()){const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';this.addAt(idx+1,nt);return;} if(!before.trim()&&!after.trim()){
el.textContent=before;block.content=before; const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
this.sync();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60);
this.dirty=true;this.autoSave();return;
}
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph'; const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
const nb=this.addAt(idx+1,nt,after);this.dirty=true;this.autoSave(); this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
requestAnimationFrame(()=>{const nel=this.getEl(nb.id);if(nel){nel.textContent=after;nel.focus();curStart(nel);}}); setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
return; this.dirty=true;this.autoSave();return;
} }
// ARROW UP // ARROWS
if(e.key==='ArrowUp'){const pos=curPos(el);if(pos===0&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();curEnd(prev);}}return;} if(e.key==='ArrowUp'){if(cp(el)===0&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
// ARROW DOWN if(e.key==='ArrowDown'){if(cp(el)>=(el.textContent||'').length&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
if(e.key==='ArrowDown'){const pos=curPos(el),len=(el.textContent||'').length;if(pos>=len&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();curStart(next);}}return;}
// BACKSPACE // BACKSPACE
if(e.key==='Backspace'){const text=el.textContent||'';if(!text.trim()){if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';el.textContent='';this.dirty=true;this.autoSave();requestAnimationFrame(()=>el.focus());}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;} if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
// SPACE: check markdown // Markdown shortcuts on space
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),10);} if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
// Ctrl shortcuts // Ctrl shortcuts
if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}} if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
@@ -322,56 +200,48 @@ function editor(pid,title,fmt,raw){
this.dirty=true;this.autoSave(); this.dirty=true;this.autoSave();
}, },
checkMd(bid){ checkMd(idx){
const idx=this.getIdx(bid);if(idx<0)return; if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||'';
const block=this.blocks[idx],el=this.getEl(bid);if(!el)return; const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
const text=el.textContent||''; this.sync();
const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'}; for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.dirty=true;this.autoSave();requestAnimationFrame(()=>{const nel=this.getEl(bid);if(nel){nel.textContent='';if(type!=='divider')nel.focus();}});return;}} if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');this.dirty=true;this.autoSave();} if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.dirty=true;this.autoSave();requestAnimationFrame(()=>{const nel=this.getEl(bid);if(nel){nel.textContent='';nel.focus();}});}
}, },
// ── Slash apply (called from SlashMenu.select) ──
applySlash(id){ applySlash(id){
const idx=SlashMenu._idx; const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
if(idx<0||idx>=this.blocks.length)return; const block=this.blocks[idx];this.sync();block.content='';
const block=this.blocks[idx],el=this.getEl(block.id); if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
if(el)el.textContent='';block.content=''; else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';}
if(id==='divider'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');} this.render();this.dirty=true;this.autoSave();
else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text'; setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);
requestAnimationFrame(()=>{const nel=this.getEl(block.id);if(nel){nel.textContent='';nel.focus();}});}
this.dirty=true;this.autoSave();
}, },
// ── Format toolbar ── _doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}},
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.dirty=true;this.autoSave();}},
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};}, 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();}, 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();}}, fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
dropImg(e,idx){const f=e.dataTransfer.files[0];if(f?.type?.startsWith('image/'))this.upFile(idx,f);},
uploadImg(idx,e){if(e.target.files[0])this.upFile(idx,e.target.files[0]);},
upFile(idx,file){const b=this.blocks[idx];const r=new FileReader();r.onload=ev=>{b.src=ev.target.result;this.dirty=true;this.autoSave();};r.readAsDataURL(file);},
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();}, pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
autoSave(){clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);}, autoSave(){clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
save(){ save(){
if(this.saving)return; if(this.saving)return;
const ct=document.getElementById('_blocksCt'); this.sync();
for(const b of this.blocks){if(b.type==='divider')continue;const el=ct?ct.querySelector(`[data-bid="${b.id}"]:not(button):not(input)`):null;if(el)b.content=el.textContent||'';} const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'Untitled';
this.saving=true; this.saving=true;
const csrf=document.cookie.match(/csrf_token=([^;]+)/); const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();}).catch(()=>{this.saving=false;}); .then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();}).catch(()=>{this.saving=false;});
}, },
md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;}, md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;},
}; };
return self; return self;
} }
// ── Global: selection → toolbar ──
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);}); document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
</script> </script>
{% endblock %} {% endblock %}
+22 -7
View File
@@ -19,8 +19,10 @@
--text-primary: #FFFFFF; --text-primary: #FFFFFF;
--text-secondary: #A0A0A0; --text-secondary: #A0A0A0;
--text-dim: #6B6B6B; --text-dim: #6B6B6B;
--text-muted: #4A4A4A;
--border: #333333; --border: #333333;
--border-color: rgba(255,255,255,0.06);
--border-light: #3a3a3a; --border-light: #3a3a3a;
/* ===== Couleurs d'accent Notion ===== */ /* ===== Couleurs d'accent Notion ===== */
@@ -70,7 +72,9 @@
--text-primary: #1A1A1A; --text-primary: #1A1A1A;
--text-secondary: #6B6B6B; --text-secondary: #6B6B6B;
--text-dim: #9B9B9B; --text-dim: #9B9B9B;
--text-muted: #B0B0B0;
--border: #E0E0E0; --border: #E0E0E0;
--border-color: rgba(0,0,0,0.06);
--border-light: #EDEDEC; --border-light: #EDEDEC;
--blue-bg: #E8F0FE; --blue-bg: #E8F0FE;
--green-bg: #E6F4EA; --green-bg: #E6F4EA;
@@ -1511,8 +1515,6 @@ input, select, textarea { font-family: inherit; color: inherit; }
overflow-y: auto; overflow-y: auto;
padding: 0; padding: 0;
min-height: 0; min-height: 0;
display: flex;
flex-direction: column;
} }
/* Page title area */ /* Page title area */
@@ -1560,7 +1562,6 @@ input, select, textarea { font-family: inherit; color: inherit; }
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
min-height: 300px; min-height: 300px;
flex: 1;
} }
.blocks-empty { .blocks-empty {
@@ -1674,10 +1675,11 @@ input, select, textarea { font-family: inherit; color: inherit; }
min-height: 28px; min-height: 28px;
word-break: break-word; word-break: break-word;
} }
.block-content:empty::before { .block-content.empty::before {
content: attr(data-placeholder); content: attr(data-placeholder);
color: #9B9A97; color: #9B9A97;
pointer-events: none; pointer-events: none;
font-style: normal;
} }
/* Headings */ /* Headings */
@@ -1700,6 +1702,16 @@ input, select, textarea { font-family: inherit; color: inherit; }
padding: 4px 2px 2px; padding: 4px 2px 2px;
} }
.block-h4 {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
padding: 4px 2px 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
/* Bulleted list */ /* Bulleted list */
.block-bullet { .block-bullet {
padding-left: 18px; padding-left: 18px;
@@ -1743,10 +1755,11 @@ input, select, textarea { font-family: inherit; color: inherit; }
min-height: 26px; min-height: 26px;
word-break: break-word; word-break: break-word;
} }
.block-todo div:empty::before { .block-content.empty::before {
content: attr(data-placeholder); content: attr(data-placeholder);
color: #9B9A97; color: #9B9A97;
pointer-events: none; pointer-events: none;
font-style: normal;
} }
/* Toggle */ /* Toggle */
@@ -1783,10 +1796,11 @@ input, select, textarea { font-family: inherit; color: inherit; }
min-height: 26px; min-height: 26px;
word-break: break-word; word-break: break-word;
} }
.block-toggle div:empty::before { .block-content.empty::before {
content: attr(data-placeholder); content: attr(data-placeholder);
color: #9B9A97; color: #9B9A97;
pointer-events: none; pointer-events: none;
font-style: normal;
} }
/* Quote */ /* Quote */
@@ -1860,10 +1874,11 @@ input, select, textarea { font-family: inherit; color: inherit; }
line-height: 1.6; line-height: 1.6;
min-height: 24px; min-height: 24px;
} }
.block-callout div:empty::before { .block-content.empty::before {
content: attr(data-placeholder); content: attr(data-placeholder);
color: #9B9A97; color: #9B9A97;
pointer-events: none; pointer-events: none;
font-style: normal;
} }
/* Image block */ /* Image block */