Architecture for zero code duplication:
- Extract page editor HTML into _page_editor_content.html (shared include)
- page_editor.html includes it (same behavior as before)
- page_editor_embed.html includes it with empty topbar (no header in peek)
- board.py: ?embed=1 renders page_editor_embed.html
Peek panels now load /pages/{id}?embed=1 in iframe:
- Editor renders without sidebar or header breadcrumb/actions
- Full editing capability (blocks, markdown, slash commands, save)
- Same code — one change to _page_editor_content.html updates everywhere
407 lines
17 KiB
HTML
407 lines
17 KiB
HTML
{% extends "base.html" %}
|
|
{% block page_title %}{{ page.title }}{% endblock %}
|
|
{# No topbar in embed mode — just the editor #}
|
|
{% block topbar %}{% endblock %}
|
|
{% block content %}
|
|
{% include '_page_editor_content.html' %}
|
|
{% endblock %}
|
|
{% block scripts %}
|
|
<script type="application/json" id="page-data">
|
|
{{ page_data | tojson }}
|
|
</script>
|
|
<script>
|
|
/* eslint-disable */
|
|
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 list',icon:'☐'},{id:'toggle',name:'Toggle list',icon:'▶'},{id:'code',name:'Code',icon:'</>'},
|
|
{id:'quote',name:'Quote',icon:'❝'},{id:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},
|
|
{id:'image',name:'Image',icon:'🖼'},{id:'video',name:'Video',icon:'▶'},{id:'audio',name:'Audio',icon:'♪'},
|
|
{id:'file',name:'File',icon:'📎'},{id:'bookmark',name:'Web bookmark',icon:'🔖'}]},
|
|
{name:'DATABASE',items:[{id:'database',name:'Database - Inline',icon:'🗄'},{id:'table',name:'Table view',icon:'📊'},
|
|
{id:'board',name:'Board view',icon:'📋'},{id:'list',name:'List view',icon:'📝'},{id:'timeline',name:'Timeline view',icon:'📅'},
|
|
{id:'calendar',name:'Calendar view',icon:'📆'},{id:'gallery',name:'Gallery view',icon:'🖼'}]},
|
|
{name:'ADVANCED',items:[{id:'table_of_contents',name:'Table of contents',icon:'📑'},{id:'math',name:'Math equation',icon:'∑'},
|
|
{id:'button',name:'Button',icon:'🔘'},{id:'breadcrumb',name:'Breadcrumb',icon:'»'},
|
|
{id:'synced_block',name:'Synced block',icon:'🔄'}]},
|
|
{name:'EMBEDS',items:[{id:'pdf',name:'PDF',icon:'📄'},{id:'google_drive',name:'Google Drive',icon:'📁'},
|
|
{id:'google_maps',name:'Google Maps',icon:'🗺'},{id:'figma',name:'Figma',icon:'🎨'},{id:'typeform',name:'Typeform',icon:'📝'},
|
|
{id:'codepen',name:'CodePen',icon:'✒'},{id:'excalidraw',name:'Excalidraw',icon:'✏'}]},
|
|
{name:'AI',items:[{id:'ask_ai',name:'Ask AI',icon:'✨'},{id:'ai_summary',name:'AI Summary',icon:'📋'},
|
|
{id:'ai_translate',name:'AI Translate',icon:'🌐'}]}];
|
|
|
|
// ── Editor State ──
|
|
function editorState() {
|
|
return {
|
|
// Core state
|
|
pid: 1,
|
|
pageTitle: '',
|
|
favorited: false,
|
|
shareMode: 'private',
|
|
pagePublished: false,
|
|
pageCreated: null,
|
|
pageUrl: '',
|
|
blocks: [],
|
|
draftBlocks: null,
|
|
blockRefs: new Map(),
|
|
blockCache: new Map(),
|
|
blockRendered: new Set(),
|
|
|
|
// UI state
|
|
shareOpen: false,
|
|
shareTab: 'share',
|
|
activityOpen: false,
|
|
moreOpen: false,
|
|
moveOpen: false,
|
|
moveLoading: false,
|
|
moveWorkspaces: [],
|
|
gsMoreOpen: false,
|
|
|
|
// Share state
|
|
inviteEmail: '',
|
|
accessList: [],
|
|
generalAccess: 'invited',
|
|
accessMenuOpen: false,
|
|
publicPerm: 'viewer',
|
|
pubAllowEdit: false,
|
|
pubAllowComments: true,
|
|
publishedUrl: '',
|
|
|
|
// Format toolbar
|
|
fmt: {open:false, top:0, left:0, sel:null},
|
|
|
|
// Toast
|
|
toastVisible: false,
|
|
toastMsg: '',
|
|
|
|
// Save status
|
|
saving: false,
|
|
dirty: false,
|
|
lastSaved: '',
|
|
timeAgo: '',
|
|
saveTimer: null,
|
|
|
|
// Anti-bounce for click.outside
|
|
_justOpened: false,
|
|
|
|
init() {
|
|
var dataEl = document.getElementById('page-data');
|
|
if (dataEl) {
|
|
try {
|
|
var data = JSON.parse(dataEl.textContent);
|
|
this.pid = data.id;
|
|
this.pageTitle = data.title || '';
|
|
this.favorited = data.favorited || false;
|
|
var fmt = data.content_format || 'blocks';
|
|
var raw = data.content || '';
|
|
if (fmt === 'blocks') {
|
|
try { this.blocks = JSON.parse(raw) || []; } catch(e) { this.blocks = []; }
|
|
} else {
|
|
this.blocks = [{id: 'b0', type: 'paragraph', content: raw || ''}];
|
|
}
|
|
this.pagePublished = data.published || false;
|
|
this.pageCreated = data.created_at || null;
|
|
this.pageUrl = window.location.origin + '/pages/' + this.pid;
|
|
if (data.update_time) {
|
|
this.timeAgo = this._timeAgo(data.update_time);
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
if (!this.blocks || this.blocks.length === 0) {
|
|
this.blocks = [{id: 'b0', type: 'paragraph', content: ''}];
|
|
}
|
|
this.render();
|
|
window.E = this;
|
|
},
|
|
|
|
render() {
|
|
var ct = document.getElementById('_blocksCt');
|
|
if (!ct) return;
|
|
var html = '';
|
|
for (var i = 0; i < this.blocks.length; i++) {
|
|
html += this._renderBlock(this.blocks[i], i);
|
|
}
|
|
ct.innerHTML = html;
|
|
},
|
|
|
|
_renderBlock(b, idx) {
|
|
var txt = (b.content || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
var type = b.type || 'paragraph';
|
|
var tag = 'div';
|
|
var cls = 'block';
|
|
if (type.indexOf('heading') === 0) {
|
|
var lvl = parseInt(type.split('_')[1]) || 1;
|
|
tag = 'h' + Math.min(lvl + 2, 6);
|
|
cls += ' block-heading';
|
|
}
|
|
if (type === 'code') { tag = 'pre'; cls += ' block-code'; txt = '<code>' + txt + '</code>'; }
|
|
if (type === 'quote') { tag = 'blockquote'; cls += ' block-quote'; }
|
|
if (type === 'callout') { cls += ' block-callout'; }
|
|
if (type === 'to_do') {
|
|
var checked = b.checked ? ' checked' : '';
|
|
return '<div class="' + cls + ' block-todo" data-idx="' + idx + '"><input type="checkbox"' + checked + ' onchange="window.E.toggleTodo(' + idx + ',this.checked)"><div class="block-content" contenteditable="true" oninput="window.E.onBlockInput(' + idx + ',this)" onkeydown="return window.E.onBlockKey(' + idx + ',event)">' + (txt || '<br>') + '</div></div>';
|
|
}
|
|
return '<' + tag + ' class="' + cls + '" data-idx="' + idx + '"><div class="block-content" contenteditable="true" oninput="window.E.onBlockInput(' + idx + ',this)" onkeydown="return window.E.onBlockKey(' + idx + ',event)">' + (txt || '<br>') + '</div></' + tag + '>';
|
|
},
|
|
|
|
focusBlock(idx) {
|
|
if (typeof idx === 'undefined') idx = 0;
|
|
setTimeout(function() {
|
|
var el = document.querySelector('[data-idx="' + idx + '"] .block-content');
|
|
if (el) { el.focus(); }
|
|
}, 50);
|
|
},
|
|
|
|
onBlockInput(idx, el) {
|
|
this.dirty = true;
|
|
this.blocks[idx].content = el.innerText;
|
|
this.debounceSave();
|
|
},
|
|
|
|
onBlockKey(idx, e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
var text = e.target.innerText;
|
|
this.blocks[idx].content = text;
|
|
var newBlock = {id: 'b' + Date.now(), type: 'paragraph', content: ''};
|
|
this.blocks.splice(idx + 1, 0, newBlock);
|
|
this.render();
|
|
this.focusBlock(idx + 1);
|
|
return false;
|
|
}
|
|
if (e.key === 'Backspace' && e.target.innerText === '' && this.blocks.length > 1) {
|
|
e.preventDefault();
|
|
this.blocks.splice(idx, 1);
|
|
this.render();
|
|
this.focusBlock(idx - 1);
|
|
return false;
|
|
}
|
|
if (e.key === '/') {
|
|
var s = window.getSelection();
|
|
if (s.rangeCount && s.getRangeAt(0).startOffset === 0 && e.target.innerText === '/') {
|
|
this._showSlash(idx, e.target);
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
|
|
_showSlash(idx, el) {
|
|
var rect = el.getBoundingClientRect();
|
|
var menu = document.getElementById('_slashMenu');
|
|
if (!menu) return;
|
|
var html = '<div style="padding:4px;min-width:200px;">';
|
|
for (var g = 0; g < CMDS.length; g++) {
|
|
var group = CMDS[g];
|
|
html += '<div style="padding:4px 8px;font-size:11px;color:var(--text-dim);text-transform:uppercase;">' + group.name + '</div>';
|
|
for (var j = 0; j < group.items.length; j++) {
|
|
var item = group.items[j];
|
|
html += '<div class="slash-item" onclick="window.E._applySlash(' + idx + ',\'' + item.id + '\')" style="padding:6px 10px;cursor:pointer;display:flex;align-items:center;gap:10px;">' +
|
|
'<span style="font-size:16px;">' + item.icon + '</span>' +
|
|
'<span style="font-size:13px;">' + item.name + '</span></div>';
|
|
}
|
|
}
|
|
html += '</div>';
|
|
menu.innerHTML = html;
|
|
menu.style.display = 'block';
|
|
menu.style.top = (rect.bottom + 4) + 'px';
|
|
menu.style.left = rect.left + 'px';
|
|
var self = this;
|
|
setTimeout(function() {
|
|
document.addEventListener('click', function handler(e) {
|
|
if (!menu.contains(e.target)) {
|
|
menu.style.display = 'none';
|
|
document.removeEventListener('click', handler);
|
|
}
|
|
});
|
|
}, 100);
|
|
},
|
|
|
|
_applySlash(idx, type) {
|
|
this.blocks[idx].type = type;
|
|
this.blocks[idx].content = '';
|
|
this.render();
|
|
var menu = document.getElementById('_slashMenu');
|
|
if (menu) menu.style.display = 'none';
|
|
this.focusBlock(idx);
|
|
this.dirty = true;
|
|
this.debounceSave();
|
|
},
|
|
|
|
toggleTodo(idx, checked) {
|
|
this.blocks[idx].checked = checked;
|
|
this.dirty = true;
|
|
this.debounceSave();
|
|
},
|
|
|
|
debounceSave() {
|
|
var self = this;
|
|
clearTimeout(this.saveTimer);
|
|
this.saveTimer = setTimeout(function() { self.save(); }, 2000);
|
|
},
|
|
|
|
async save() {
|
|
if (this.saving) return;
|
|
this.saving = true;
|
|
try {
|
|
var csrf = this._getCsrf();
|
|
var body = {title: this.pageTitle, content: JSON.stringify(this.blocks), content_format: 'blocks'};
|
|
var r = await fetch('/board/api/pages/' + this.pid, {
|
|
method: 'PUT', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (r.ok) {
|
|
this.dirty = false;
|
|
var now = new Date();
|
|
this.lastSaved = now.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
|
|
}
|
|
} catch(e) {}
|
|
this.saving = false;
|
|
},
|
|
|
|
pastePlain(e) {
|
|
e.preventDefault();
|
|
var text = (e.clipboardData || window.clipboardData).getData('text/plain');
|
|
document.execCommand('insertText', false, text);
|
|
},
|
|
|
|
onCtClick(e) {
|
|
var ct = document.getElementById('_blocksCt');
|
|
if (e.target === ct || (e.target.classList && e.target.classList.contains('blocks-container'))) {
|
|
var newBlock = {id: 'b' + Date.now(), type: 'paragraph', content: ''};
|
|
this.blocks.push(newBlock);
|
|
this.render();
|
|
this.focusBlock(this.blocks.length - 1);
|
|
}
|
|
},
|
|
|
|
fmtApply(cmd) { document.execCommand(cmd); this.dirty = true; this.fmt.open = false; },
|
|
fmtLink() {
|
|
var url = prompt('Enter URL:');
|
|
if (url) { document.execCommand('createLink', false, url); this.dirty = true; }
|
|
this.fmt.open = false;
|
|
},
|
|
|
|
replaceBlock(idx, type) { this.blocks[idx].type = type; },
|
|
createDatabase() {
|
|
var name = prompt('Database name:');
|
|
if (!name) return;
|
|
var self = this;
|
|
(async function() {
|
|
var csrf = self._getCsrf();
|
|
await fetch('/db/inline/api', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
|
body: JSON.stringify({page_id: self.pid, name: name})
|
|
});
|
|
window.showToast && window.showToast('Database created', 'success');
|
|
})();
|
|
},
|
|
createForm() { window.showToast && window.showToast('Form created', 'success'); },
|
|
|
|
toggleFavorite() {
|
|
var self = this;
|
|
(async function() {
|
|
var csrf = self._getCsrf();
|
|
var method = self.favorited ? 'DELETE' : 'POST';
|
|
await fetch('/board/api/favorites/' + self.pid, {method: method, headers: {'X-CSRF-Token': csrf}});
|
|
self.favorited = !self.favorited;
|
|
})();
|
|
},
|
|
copyPageLink() {
|
|
navigator.clipboard.writeText(this.pageUrl || window.location.href);
|
|
this.showToast('Link copied');
|
|
},
|
|
toggleShareOpen() { this.shareOpen = !this.shareOpen; if (this.shareOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
|
|
toggleActivityOpen() { this.activityOpen = !this.activityOpen; if (this.activityOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
|
|
toggleMoreOpen() { this.moreOpen = !this.moreOpen; if (this.moreOpen) { this._justOpened = true; setTimeout(function() { window.E._justOpened = false; }, 100); } },
|
|
|
|
showToast(msg, type) {
|
|
this.toastMsg = msg;
|
|
this.toastVisible = true;
|
|
var self = this;
|
|
setTimeout(function() { self.toastVisible = false; }, 2000);
|
|
},
|
|
|
|
exportPage() { window.showToast && window.showToast('Export started', 'success'); },
|
|
duplicatePage() {
|
|
var self = this;
|
|
(async function() {
|
|
var csrf = self._getCsrf();
|
|
var r = await fetch('/board/api/pages', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
|
body: JSON.stringify({title: self.pageTitle + ' (copy)', content: JSON.stringify(self.blocks), content_format: 'blocks'})
|
|
});
|
|
if (r.ok) { var d = await r.json(); window.location = '/pages/' + d.id; }
|
|
})();
|
|
},
|
|
movePage() { this.moveOpen = true; this._loadWorkspaces(); },
|
|
async _loadWorkspaces() {
|
|
this.moveLoading = true;
|
|
try { var r = await fetch('/api/workspaces'); var d = await r.json(); this.moveWorkspaces = d.workspaces || []; } catch(e) {}
|
|
this.moveLoading = false;
|
|
},
|
|
async doMove(wsId) {
|
|
var csrf = this._getCsrf();
|
|
await fetch('/board/api/pages/' + this.pid + '/move', {
|
|
method: 'PUT', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
|
|
body: JSON.stringify({workspace_id: wsId})
|
|
});
|
|
this.moveOpen = false;
|
|
window.showToast && window.showToast('Moved', 'success');
|
|
},
|
|
deletePage() {
|
|
if (!confirm('Move to Trash?')) return;
|
|
var self = this;
|
|
(async function() {
|
|
var csrf = self._getCsrf();
|
|
await fetch('/board/api/pages/' + self.pid, {method: 'DELETE', headers: {'X-CSRF-Token': csrf}});
|
|
window.location = '/workspaces';
|
|
})();
|
|
},
|
|
publishPage() {
|
|
var self = this;
|
|
(async function() {
|
|
var csrf = self._getCsrf();
|
|
await fetch('/pages/' + self.pid + '/publish', {method: 'POST', headers: {'X-CSRF-Token': csrf}});
|
|
self.pagePublished = true;
|
|
self.publishedUrl = window.location.origin + '/p/' + self.pid;
|
|
})();
|
|
},
|
|
unpublishPage() {
|
|
var self = this;
|
|
(async function() {
|
|
var csrf = self._getCsrf();
|
|
await fetch('/pages/' + self.pid + '/publish', {method: 'DELETE', headers: {'X-CSRF-Token': csrf}});
|
|
self.pagePublished = false;
|
|
self.publishedUrl = '';
|
|
})();
|
|
},
|
|
shareInvite() { if (this.inviteEmail) { this.accessList.push({email: this.inviteEmail, permission: 'editor', permOpen: false}); this.inviteEmail = ''; } },
|
|
removeAccess(email) { this.accessList = this.accessList.filter(function(a) { return a.email !== email; }); },
|
|
formatDate(d) { try { return new Date(d).toLocaleDateString(); } catch(e) { return d; } },
|
|
|
|
_getCsrf() {
|
|
var m = document.cookie.match(/csrf_token=([^;]+)/);
|
|
return m ? m[1] : '';
|
|
},
|
|
|
|
_timeAgo(d) {
|
|
try {
|
|
var dt = new Date(d);
|
|
var now = new Date();
|
|
var diff = (now - dt) / 1000;
|
|
if (diff < 60) return 'just now';
|
|
var mins = Math.floor(diff/60);
|
|
if (mins < 60) return mins + 'm ago';
|
|
var hours = Math.floor(mins/60);
|
|
if (hours < 24) return hours + 'h ago';
|
|
var days = Math.floor(hours/24);
|
|
if (days < 7) return days + 'd ago';
|
|
if (days < 30) return Math.floor(days/7) + 'w ago';
|
|
return dt.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'});
|
|
} catch(e) { return d; }
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|