734 lines
45 KiB
HTML
734 lines
45 KiB
HTML
{% extends "base.html" %} {% block page_icon %}📄{% endblock %} {% block
|
|
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|
<!-- Page editor uses its own topbar -->
|
|
{% endblock %} {% block content %}
|
|
<div class="page-editor-wrapper" x-data="editorState()">
|
|
<!-- ═══════════ Top Action Bar ═══════════ -->
|
|
<div class="page-topbar">
|
|
<div class="page-topbar-right" style="position: relative">
|
|
<span class="topbar-edited">Edited just now</span>
|
|
<button
|
|
class="page-action-btn share-btn"
|
|
@click="shareOpen = !shareOpen"
|
|
>
|
|
🔒 Share <span class="chevron-down">▾</span>
|
|
</button>
|
|
<button
|
|
class="page-action-btn"
|
|
@click="copyPageLink"
|
|
title="Copy link"
|
|
>
|
|
🔗
|
|
</button>
|
|
<button class="page-action-btn star-btn" @click="toggleFavorite">
|
|
<span
|
|
x-text="favorited ? '⭐' : '☆'"
|
|
:class="{ starred: favorited }"
|
|
></span>
|
|
</button>
|
|
<button class="page-action-btn" @click="moreOpen = !moreOpen">
|
|
⋮
|
|
</button>
|
|
|
|
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
|
|
<div
|
|
class="share-dialog"
|
|
x-show="shareOpen"
|
|
@click.away="shareOpen = false"
|
|
x-transition.opacity.scale.origin.top.right
|
|
>
|
|
<!-- Header with tabs -->
|
|
<div class="sd-header">
|
|
<button
|
|
class="sd-tab"
|
|
:class="{ active: shareTab === 'share' }"
|
|
@click="shareTab = 'share'"
|
|
>
|
|
Share
|
|
</button>
|
|
<button
|
|
class="sd-tab"
|
|
:class="{ active: shareTab === 'publish' }"
|
|
@click="shareTab = 'publish'"
|
|
>
|
|
Publish
|
|
<span class="sd-badge" x-show="pagePublished"></span>
|
|
</button>
|
|
<button class="sd-close" @click="shareOpen = false">
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ═══════ SHARE TAB ═══════ -->
|
|
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
|
|
<!-- Invite input -->
|
|
<div class="sd-invite-row">
|
|
<input
|
|
type="email"
|
|
class="sd-input"
|
|
placeholder="Add people, groups or emails..."
|
|
x-model="inviteEmail"
|
|
@keydown.enter="invitePerson"
|
|
/>
|
|
<button class="sd-btn-primary" @click="invitePerson">
|
|
Invite
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Context card (shown when independent permissions) -->
|
|
<div class="sd-context-card" x-show="false">
|
|
Share settings on this page are unlinked from parent
|
|
page
|
|
</div>
|
|
|
|
<!-- Participants list -->
|
|
<div class="sd-participants" x-show="accessList.length">
|
|
<template x-for="a in accessList" :key="a.email">
|
|
{% raw %}
|
|
<div class="sd-participant">
|
|
<div class="sd-participant-info">
|
|
<div
|
|
class="sd-avatar"
|
|
x-text="a.email[0].toUpperCase()"
|
|
></div>
|
|
<div>
|
|
<div
|
|
class="sd-participant-name"
|
|
x-text="a.email"
|
|
></div>
|
|
<div
|
|
class="sd-participant-email"
|
|
x-text="a.email"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="sd-perm-btn"
|
|
@click="a.permOpen = !a.permOpen"
|
|
>
|
|
<span
|
|
x-text="a.permission === 'editor' ? 'Full access' : a.permission === 'commenter' ? 'Can comment' : 'Can view'"
|
|
></span>
|
|
▾
|
|
</button>
|
|
<div
|
|
class="sd-perm-menu"
|
|
x-show="a.permOpen"
|
|
@click.away="a.permOpen = false"
|
|
>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: a.permission === 'editor' }"
|
|
@click="a.permission='editor'; a.permOpen=false"
|
|
>
|
|
<span>Can edit</span>
|
|
<span class="sd-perm-desc"
|
|
>Edit, suggest and comment</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: a.permission === 'commenter' }"
|
|
@click="a.permission='commenter'; a.permOpen=false"
|
|
>
|
|
<span>Can comment</span>
|
|
<span class="sd-perm-desc"
|
|
>Suggest and comment only</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: a.permission === 'viewer' }"
|
|
@click="a.permission='viewer'; a.permOpen=false"
|
|
>
|
|
<span>Can view</span>
|
|
<span class="sd-perm-desc"
|
|
>Read only</span
|
|
>
|
|
</div>
|
|
<div class="sd-perm-sep"></div>
|
|
<div
|
|
class="sd-perm-option danger"
|
|
@click="removeAccess(a.email); a.permOpen=false"
|
|
>
|
|
Remove
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endraw %}
|
|
</template>
|
|
</div>
|
|
|
|
<div class="sd-sep"></div>
|
|
|
|
<!-- General Access -->
|
|
<div class="sd-section">
|
|
<div class="sd-section-title">General access</div>
|
|
<div style="position: relative">
|
|
<button
|
|
class="sd-access-btn"
|
|
@click="accessMenuOpen = !accessMenuOpen"
|
|
>
|
|
<span
|
|
x-text="generalAccess === 'invited' ? '🔒 Only people invited' : '🌐 Anyone with the link'"
|
|
></span>
|
|
<span class="chevron-down">▾</span>
|
|
</button>
|
|
<div
|
|
class="sd-access-menu"
|
|
x-show="accessMenuOpen"
|
|
@click.away="accessMenuOpen = false"
|
|
x-transition
|
|
>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: generalAccess === 'invited' }"
|
|
@click="generalAccess='invited'; accessMenuOpen=false"
|
|
>
|
|
<span>🔒 Only people invited</span>
|
|
<span class="sd-perm-desc"
|
|
>People need to be invited</span
|
|
>
|
|
</div>
|
|
<div
|
|
class="sd-perm-option"
|
|
:class="{ active: generalAccess === 'anyone' }"
|
|
@click="generalAccess='anyone'; accessMenuOpen=false"
|
|
>
|
|
<span>🌐 Anyone with the link</span>
|
|
<span class="sd-perm-desc"
|
|
>Can view the page</span
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Public permission when link is open -->
|
|
<div
|
|
x-show="generalAccess === 'anyone'"
|
|
style="
|
|
margin-top: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
"
|
|
>
|
|
<span
|
|
style="
|
|
font-size: 13px;
|
|
color: var(--text-primary);
|
|
"
|
|
>Anyone with the link</span
|
|
>
|
|
<select class="sd-select-sm" x-model="publicPerm">
|
|
<option value="viewer">Can view</option>
|
|
<option value="commenter">Can comment</option>
|
|
<option value="editor">Can edit</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sd-sep"></div>
|
|
|
|
<!-- Footer -->
|
|
<div class="sd-footer">
|
|
<a href="#" class="sd-footer-link" @click.prevent
|
|
>? Learn about sharing</a
|
|
>
|
|
<button class="sd-footer-action" @click="copyPageLink">
|
|
🔗 Copy link
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════ PUBLISH TAB ═══════ -->
|
|
<div
|
|
class="sd-body"
|
|
x-show="shareTab === 'publish'"
|
|
x-transition
|
|
>
|
|
<!-- Before publishing -->
|
|
<div x-show="!pagePublished" class="sd-publish-hero">
|
|
<div class="sd-publish-preview">
|
|
<div class="sd-preview-bar"></div>
|
|
<div class="sd-preview-title">
|
|
🌐 {{ page.title }}
|
|
</div>
|
|
</div>
|
|
<h3 class="sd-publish-heading">Publish to web</h3>
|
|
<p class="sd-publish-desc">
|
|
Make this page visible to anyone on the internet.
|
|
You can share the link with anyone.
|
|
</p>
|
|
<button
|
|
class="sd-btn-primary sd-btn-full"
|
|
@click="pagePublished = true"
|
|
>
|
|
Publish
|
|
</button>
|
|
<p class="sd-publish-note">
|
|
Your page will be visible to anyone with the link.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- After publishing -->
|
|
<div x-show="pagePublished">
|
|
<div class="sd-url-bar">
|
|
<span
|
|
style="font-size: 13px; color: var(--text-dim)"
|
|
>{{ workspace_key or 'flowdeck' }}</span
|
|
>
|
|
<input
|
|
type="text"
|
|
class="sd-url-input"
|
|
:value="publishedUrl || pageUrl"
|
|
readonly
|
|
@click="$event.target.select()"
|
|
/>
|
|
<button
|
|
class="sd-btn-primary"
|
|
style="
|
|
height: 32px;
|
|
padding: 0 12px;
|
|
font-size: 12px;
|
|
"
|
|
@click="copyPageLink"
|
|
>
|
|
Copy link
|
|
</button>
|
|
</div>
|
|
|
|
<div class="sd-sep"></div>
|
|
|
|
<!-- Settings list -->
|
|
<div class="sd-settings">
|
|
<div class="sd-setting-row">
|
|
<span>🔗 Link expires</span>
|
|
<span style="color: var(--text-dim)"
|
|
>Never ▾</span
|
|
>
|
|
</div>
|
|
<div class="sd-setting-row">
|
|
<span>🔍 Search engine indexing</span>
|
|
<span style="color: var(--text-dim)"
|
|
>Off ▾</span
|
|
>
|
|
</div>
|
|
<div class="sd-setting-row">
|
|
<span>📄 Allow duplicate as template</span>
|
|
<label class="toggle-switch sm">
|
|
<input type="checkbox" checked />
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<div class="sd-setting-row">
|
|
<span>✏️ Allow editing</span>
|
|
<label class="toggle-switch sm">
|
|
<input
|
|
type="checkbox"
|
|
x-model="pubAllowEdit"
|
|
/>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
<div class="sd-setting-row">
|
|
<span>💬 Allow comments</span>
|
|
<label class="toggle-switch sm">
|
|
<input
|
|
type="checkbox"
|
|
x-model="pubAllowComments"
|
|
checked
|
|
/>
|
|
<span class="toggle-slider"></span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sd-sep"></div>
|
|
|
|
<button
|
|
class="sd-danger-btn"
|
|
@click="pagePublished = false"
|
|
>
|
|
Unpublish
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- More menu dropdown -->
|
|
<div
|
|
class="more-menu"
|
|
x-show="moreOpen"
|
|
@click.away="moreOpen = false"
|
|
x-transition
|
|
>
|
|
<div class="more-menu-item" @click="exportPage">↗ Export</div>
|
|
<div class="more-menu-item" @click="duplicatePage">
|
|
📋 Duplicate
|
|
</div>
|
|
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
|
<div class="more-menu-sep"></div>
|
|
<div class="more-menu-item danger" @click="deletePage">
|
|
🗑️ Move to Trash
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ═══════════ Page Content ═══════════ -->
|
|
<div class="page-cover-area">
|
|
<div class="page-title-block">
|
|
<span class="page-icon-emoji">📄</span>
|
|
<div
|
|
class="page-title-input"
|
|
contenteditable="true"
|
|
id="_titleEl"
|
|
@input="dirty=true;save()"
|
|
@keydown.enter.prevent="focusBlock()"
|
|
@paste.prevent="pastePlain($event)"
|
|
spellcheck="false"
|
|
>
|
|
{{ page.title }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="blocks-container"
|
|
id="_blocksCt"
|
|
@click="onCtClick($event)"
|
|
></div>
|
|
<div
|
|
id="_slashMenu"
|
|
class="slash-menu"
|
|
style="display: none; position: fixed; z-index: 999"
|
|
></div>
|
|
|
|
<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('italic')"><em>I</em></button>
|
|
<button @click="fmtApply('underline')"><u>U</u></button>
|
|
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
|
<button @click="fmtLink()">🔗</button>
|
|
</div>
|
|
|
|
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
|
<div
|
|
class="sd-toast"
|
|
x-show="toastVisible"
|
|
x-transition
|
|
x-text="toastMsg"
|
|
></div>
|
|
|
|
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
|
|
<div
|
|
class="get-started-toolbar"
|
|
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
|
|
>
|
|
<span class="gs-label">Get started with</span>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'paragraph');blocks[0].content='Ask AI to write...';render()"
|
|
>
|
|
<span class="gs-icon">✨</span> Ask AI
|
|
</button>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
|
|
>
|
|
<span class="gs-icon">📝</span> AI meeting note
|
|
</button>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()"
|
|
>
|
|
<span class="gs-icon">🗄️</span> Database
|
|
</button>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()"
|
|
>
|
|
<span class="gs-icon">📋</span> Form
|
|
</button>
|
|
<button
|
|
class="gs-pill"
|
|
@click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()"
|
|
>
|
|
<span class="gs-icon">📑</span> Templates
|
|
</button>
|
|
<div style="position: relative">
|
|
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
|
<span>⋯</span>
|
|
</button>
|
|
<div
|
|
class="gs-more-dropdown"
|
|
x-show="gsMoreOpen"
|
|
@click.away="gsMoreOpen = false"
|
|
x-transition
|
|
>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📊 Table
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📋 Board
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📝 List
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📅 Timeline
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
🗓️ Calendar
|
|
</div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
🖼️ Gallery
|
|
</div>
|
|
<div class="gs-more-sep"></div>
|
|
<div class="gs-more-item" @click="gsMoreOpen=false">
|
|
📥 Import
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-statusbar">
|
|
<span x-show="saving">Saving...</span>
|
|
<span x-show="!saving&&dirty">Unsaved</span>
|
|
<span x-show="!saving&&!dirty&&lastSaved"
|
|
>Saved <span x-text="lastSaved"></span
|
|
></span>
|
|
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
|
|
</div>
|
|
</div>
|
|
{% endblock %} {% block scripts %}
|
|
<script type="application/json" id="page-data">
|
|
{{ page_data | tojson }}
|
|
</script>
|
|
<script>
|
|
const CMDS=[{name:'BASIC',items:[
|
|
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
|
{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.'},
|
|
{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;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,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
|
|
const SM={_o:false,_i:-1,_s:0,_q:'',_it:[],
|
|
init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML='';
|
|
const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...';
|
|
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';
|
|
inp.oninput=()=>SM.flt();inp.onkeydown=ev=>SM._kd(ev);
|
|
const hdr=document.createElement('div');hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';hdr.appendChild(inp);
|
|
const list=document.createElement('div');list.style.cssText='overflow-y:auto;max-height:340px';
|
|
list.addEventListener('click',ev=>{const it=ev.target.closest('.slash-item');if(it){ev.preventDefault();SM.sel(it.dataset.sid);}});
|
|
e.appendChild(hdr);e.appendChild(list);
|
|
SM._e=e;SM._in=inp;SM._li=list;
|
|
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
|
|
},
|
|
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
|
|
const r=el.getBoundingClientRect();SM._e.style.display='block';
|
|
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
|
|
SM._e.style.left=Math.min(r.left,window.innerWidth-324)+'px';
|
|
setTimeout(()=>SM._in.focus(),10);SM._o=true;},
|
|
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');},
|
|
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
|
|
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;
|
|
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>`;
|
|
items.forEach(c=>{SM._it.push(c);
|
|
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">
|
|
<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></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();}
|
|
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.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;}};
|
|
|
|
function renderBlock(b,idx){
|
|
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></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...':'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">${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>`;
|
|
}
|
|
|
|
window.E=null;
|
|
function editorState(){
|
|
return {
|
|
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
|
|
fmt:{open:false,top:0,left:0,idx:-1},
|
|
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
|
|
shareTab:'share',favorited:false,
|
|
pagePublished:{{ page_published | tojson }},
|
|
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
|
pubAllowEdit:false,pubAllowComments:true,
|
|
pageUrl:window.location.href,publishedUrl:'',
|
|
inviteEmail:'',invitePermission:'editor',accessList:[],
|
|
toastVisible:false,toastMsg:'',
|
|
init(){
|
|
_bid=Math.floor(Math.random()*10000);
|
|
const dataEl=document.getElementById('page-data');
|
|
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';const raw=data.content||'';
|
|
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){}}
|
|
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
|
window.E=this;SM.init();this.render();
|
|
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
|
|
ct.addEventListener('keydown',e=>this.onKd(e));
|
|
ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();});
|
|
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();}});
|
|
},100);
|
|
},
|
|
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;},
|
|
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)};},
|
|
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);}}}},
|
|
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');};});
|
|
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
|
},
|
|
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
|
|
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);},
|
|
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);},
|
|
|
|
toggleFavorite(){
|
|
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
|
const m=this.favorited?'DELETE':'POST';
|
|
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
|
|
.then(r=>r.json()).then(()=>{this.favorited=!this.favorited;}).catch(()=>{});
|
|
},
|
|
togglePublish(){this.pagePublished=!this.pagePublished;this.saveShare();},
|
|
saveShare(){
|
|
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
|
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).catch(()=>{});
|
|
},
|
|
copyPageLink(){
|
|
const url = this.pageUrl;
|
|
const fallback = () => {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = url; ta.style.position='fixed'; ta.style.opacity='0';
|
|
document.body.appendChild(ta); ta.select();
|
|
document.execCommand('copy'); document.body.removeChild(ta);
|
|
};
|
|
try {
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
this.showToast('Link copied to clipboard');
|
|
}).catch(() => { fallback(); this.showToast('Link copied to clipboard'); });
|
|
} catch(e) { fallback(); this.showToast('Link copied to clipboard'); }
|
|
},
|
|
showToast(msg){
|
|
this.toastMsg = msg; this.toastVisible = true;
|
|
clearTimeout(this._toastTimer);
|
|
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
|
|
},
|
|
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
|
|
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
|
|
exportPage(){this.moreOpen=false;},
|
|
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}§ion=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{alert('Duplicate failed');});},
|
|
movePage(){this.moreOpen=false;},
|
|
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{alert('Failed');});},
|
|
|
|
onKd(e){
|
|
const ab=this.getActiveBlock();if(!ab)return;
|
|
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
|
if(SM.isOpen){
|
|
if(e.key==='Escape'){e.preventDefault();SM.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==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
|
|
return;
|
|
}
|
|
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===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
|
|
if(e.key==='Enter'){
|
|
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
|
|
e.preventDefault();
|
|
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.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';
|
|
this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
|
|
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
|
|
this.dirty=true;this.autoSave();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;}
|
|
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==='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;}
|
|
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
|
|
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.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
|
|
this.dirty=true;this.autoSave();
|
|
},
|
|
checkMd(idx){
|
|
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 sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
|
|
this.sync();
|
|
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;}}
|
|
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
|
|
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);}
|
|
},
|
|
applySlash(id){
|
|
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
|
const block=this.blocks[idx];this.sync();block.content='';
|
|
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
|
|
else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';}
|
|
this.render();this.dirty=true;this.autoSave();
|
|
setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);
|
|
},
|
|
_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};},
|
|
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();}},
|
|
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);},
|
|
save(){
|
|
if(this.saving)return;
|
|
this.sync();
|
|
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
|
|
this.saving=true;
|
|
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;})})})
|
|
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
|
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
|
}).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('### ')&&!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;},
|
|
};
|
|
}
|
|
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>
|
|
{% endblock %}
|