flask-vim-docs/app/templates/document_edit.html
2025-04-14 22:37:25 +02:00

532 lines
No EOL
20 KiB
HTML

{% extends "base.html" %}
{% block title %}{% if document %}Edit: {{ document.title }}{% else %}New Document{% endif %} - Vim Docs{% endblock %}
{% block header_title %}{% if document %}Edit: {{ document.title }}{% else %}New Document{% endif %}{% endblock %}
{% block header_actions %}
<button id="save-document" class="inline-flex items-center px-4 py-2 bg-primary text-black rounded-md hover:bg-primary-dark transition-colors">
<i class="mdi mdi-content-save mr-2"></i> Save
</button>
{% if document and document.id %}
<a href="{{ url_for('main.export_document', doc_id=document.id) }}" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors ml-2">
<i class="mdi mdi-download mr-2"></i> Export
</a>
{% endif %}
<button id="toggle-preview" class="inline-flex items-center px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors ml-2">
<i class="mdi mdi-eye mr-2"></i> Toggle Preview
</button>
{% endblock %}
{% block extra_css %}
<!-- CodeMirror CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/dracula.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/dialog/dialog.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/hint/show-hint.min.css">
<!-- GitHub Markdown CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.2.0/github-markdown-dark.min.css">
<style>
/* Editor-specific styles */
.editor-container {
display: flex;
height: calc(100vh - 64px);
overflow: hidden;
}
.editor-pane, .preview-pane {
flex: 1;
height: 100%;
overflow: auto;
transition: all 0.3s ease;
}
.editor-pane {
position: relative;
font-family: 'CascadyaCove NF', monospace;
}
.preview-pane {
padding: 0;
border-left: 1px solid #374151;
background-color: #0d1117; /* GitHub dark theme background */
}
.CodeMirror {
font-family: 'CascadyaCove NF', monospace !important;
height: 100% !important;
font-size: 15px;
}
.split-horizontal .editor-container {
flex-direction: row;
}
.split-vertical .editor-container {
flex-direction: column;
}
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
color: #c9d1d9; /* GitHub dark theme text color */
}
/* GitHub-style admonitions/alerts */
.markdown-body .admonition {
padding: 1rem;
border-left: 4px solid;
margin: 1em 0;
border-radius: 6px;
background-color: rgba(175, 184, 193, 0.2);
}
.markdown-body .admonition-title {
font-weight: 600;
margin-top: 0;
}
/* Regular blockquotes */
.markdown-body blockquote {
padding: 0.5rem 1rem;
color: #8b949e;
border-left: 0.25em solid #30363d;
margin: 1em 0;
background-color: rgba(55, 65, 81, 0.1);
}
.markdown-body blockquote > :first-child {
margin-top: 0;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
.markdown-body .admonition-note {
border-color: #2b6eff;
background-color: rgba(43, 110, 255, 0.1);
}
.markdown-body .admonition-note .admonition-title {
color: #2b6eff;
}
.markdown-body .admonition-tip {
border-color: #3fb950;
background-color: rgba(63, 185, 80, 0.1);
}
.markdown-body .admonition-tip .admonition-title {
color: #3fb950;
}
.markdown-body .admonition-important {
border-color: #a371f7;
background-color: rgba(163, 113, 247, 0.1);
}
.markdown-body .admonition-important .admonition-title {
color: #a371f7;
}
.markdown-body .admonition-warning {
border-color: #d29922;
background-color: rgba(210, 153, 34, 0.1);
}
.markdown-body .admonition-warning .admonition-title {
color: #d29922;
}
.markdown-body .admonition-caution {
border-color: #f85149;
background-color: rgba(248, 81, 73, 0.1);
}
.markdown-body .admonition-caution .admonition-title {
color: #f85149;
}
.markdown-body .admonition-danger {
border-color: #cf222e;
background-color: rgba(207, 34, 46, 0.1);
}
.markdown-body .admonition-danger .admonition-title {
color: #cf222e;
}
</style>
{% endblock %}
{% block content %}
<!-- Document info bar -->
<div class="bg-gray-800 rounded-lg mb-4 shadow-lg overflow-hidden">
<div class="p-4 grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="doc-title" class="block text-sm font-medium text-gray-400 mb-1">Document Title</label>
<input type="text" id="doc-title"
value="{% if document %}{{ document.title }}{% else %}Untitled Document{% endif %}"
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div>
<label for="doc-category" class="block text-sm font-medium text-gray-400 mb-1">Category</label>
<select id="doc-category" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<!-- Categories will be populated via JavaScript -->
</select>
</div>
<div>
<label for="doc-tags" class="block text-sm font-medium text-gray-400 mb-1">Tags</label>
<div class="relative">
<input type="text" id="doc-tags" placeholder="Add tags..."
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-primary focus:border-primary">
<div class="tag-list flex flex-wrap gap-1 mt-2">
{% if document and document.tags %}
{% for tag in document.tags %}
<span class="tag px-2 py-1 bg-primary/20 text-primary rounded-full text-xs flex items-center">
{{ tag.name }}
<span class="remove-tag ml-1 cursor-pointer">&times;</span>
</span>
{% endfor %}
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Editor container -->
<div class="editor-container bg-gray-800 rounded-lg overflow-hidden shadow-lg">
<div class="editor-pane">
<textarea id="editor">{% if document %}{{ document.content }}{% endif %}</textarea>
</div>
<div class="preview-pane">
<div id="preview" class="markdown-body"></div>
</div>
</div>
<!-- Save notification -->
<div id="save-notification" class="fixed bottom-4 right-4 bg-green-500/90 text-white px-4 py-2 rounded-md shadow-lg transform translate-y-16 opacity-0 transition-all duration-300 flex items-center">
<i class="mdi mdi-check-circle mr-2"></i>
<span>Document saved successfully!</span>
</div>
{% endblock %}
{% block extra_js %}
<!-- CodeMirror JS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/markdown/markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/continuelist.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/closebrackets.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/mode/overlay.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/selection/active-line.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/dialog/dialog.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/search/searchcursor.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/search/search.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/keymap/vim.min.js"></script>
<!-- Marked.js for Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked@4.0.16/marked.min.js"></script>
<script>
// Initialize CodeMirror
const editor = CodeMirror.fromTextArea(document.getElementById('editor'), {
mode: 'markdown',
theme: 'dracula',
lineNumbers: true,
lineWrapping: true,
indentWithTabs: false,
tabSize: 2,
keyMap: 'vim',
styleActiveLine: true,
autoCloseBrackets: true,
extraKeys: {
"Enter": "newlineAndIndentContinueMarkdownList"
}
});
// Setup marked.js for markdown rendering
marked.setOptions({
gfm: true, // GitHub flavored markdown
breaks: true, // Convert newlines to <br>
headerIds: true,
sanitize: false, // Allow raw HTML
smartLists: true,
smartypants: true, // Typographic fixes
highlight: function(code) {
return `<pre><code>${code}</code></pre>`;
}
});
// Custom renderer for handling GitHub-style alert blocks
const renderer = new marked.Renderer();
// Store the original blockquote renderer
const originalBlockquote = renderer.blockquote;
// Override blockquote to support admonitions
renderer.blockquote = function(quote) {
// Check for GitHub-style admonition format [!NOTE], [!TIP], etc.
const text = quote.replace(/<\/?p>/g, ''); // Extract text without p tags
const admonitionRegex = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION|DANGER)\]\s*([\s\S]*)/i;
const match = text.match(admonitionRegex);
if (match) {
const type = match[1].toLowerCase();
const title = match[1].charAt(0).toUpperCase() + match[1].slice(1).toLowerCase();
const content = match[2] ? match[2].trim() : '';
return `<div class="admonition admonition-${type}">
<p class="admonition-title">${title}</p>
<p>${content}</p>
</div>`;
}
// Fall back to the original blockquote renderer
return originalBlockquote.call(this, quote);
};
marked.use({ renderer });
// Update markdown preview
function updatePreview() {
const content = editor.getValue();
const preview = document.getElementById('preview');
preview.innerHTML = marked.parse(content);
}
// Initialize
document.addEventListener('DOMContentLoaded', function() {
// Populate categories dropdown with hierarchical structure
populateCategoriesDropdown();
// Initial preview
updatePreview();
// Function to create hierarchical category dropdown
function populateCategoriesDropdown() {
const categorySelect = document.getElementById('doc-category');
const categories = {{ categories|tojson|safe }};
const preselectedId = {% if document and document.category_id %}{{ document.category_id }}{% elif preselected_category_id %}{{ preselected_category_id }}{% else %}null{% endif %};
// Find the root category
const rootCategory = categories.find(c => c.is_root);
let rootCategoryId = null;
if (rootCategory) {
rootCategoryId = rootCategory.id;
}
function addCategoryOptions(categoryList, depth = 0) {
categoryList.forEach(category => {
const option = document.createElement('option');
option.value = category.id;
// Create indentation for hierarchy
const indent = '\u00A0\u00A0\u00A0\u00A0'.repeat(depth);
let prefix = '';
if (depth > 0) {
prefix = '└─ ';
}
option.textContent = indent + prefix + category.name;
// Select this option if it matches the preselected ID
// Or if this is the root category and no preselection was made
option.selected = (category.id == preselectedId) ||
(category.is_root && !preselectedId);
categorySelect.appendChild(option);
// Add children recursively if any
if (category.children && category.children.length > 0) {
addCategoryOptions(category.children, depth + 1);
}
});
}
// Get root categories and their children
const rootCategories = categories.filter(c => c.parent_id === null);
addCategoryOptions(rootCategories);
console.log("Preselected category ID:", preselectedId || (rootCategoryId + " (root by default)"));
}
// Add debounce function for efficiency
function debounce(func, wait) {
let timeout;
return function() {
const context = this;
const args = arguments;
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait);
};
}
// Debounced preview update
const debouncedUpdate = debounce(updatePreview, 300);
// Update preview when content changes
editor.on('change', debouncedUpdate);
// Sync scroll between editor and preview
const previewPane = document.querySelector('.preview-pane');
// Editor to Preview scroll sync
editor.on('scroll', function() {
const scrollInfo = editor.getScrollInfo();
const ratio = scrollInfo.top / (scrollInfo.height - scrollInfo.clientHeight);
const previewHeight = previewPane.scrollHeight - previewPane.clientHeight;
// Add a flag to prevent infinite scroll loop
if (!previewPane.isScrolling) {
editor.isScrolling = true;
previewPane.scrollTop = ratio * previewHeight;
setTimeout(() => { editor.isScrolling = false; }, 50);
}
});
// Preview to Editor scroll sync
previewPane.addEventListener('scroll', function() {
if (!editor.isScrolling) {
const ratio = previewPane.scrollTop / (previewPane.scrollHeight - previewPane.clientHeight);
const scrollInfo = editor.getScrollInfo();
const editorHeight = scrollInfo.height - scrollInfo.clientHeight;
previewPane.isScrolling = true;
editor.scrollTo(null, ratio * editorHeight);
setTimeout(() => { previewPane.isScrolling = false; }, 50);
}
});
// Toggle preview pane
const toggleBtn = document.getElementById('toggle-preview');
const editorContainer = document.querySelector('.editor-container');
const previewElement = document.querySelector('.preview-pane');
toggleBtn.addEventListener('click', function() {
previewElement.classList.toggle('hidden');
if (previewElement.classList.contains('hidden')) {
toggleBtn.innerHTML = '<i class="mdi mdi-eye-outline mr-2"></i> Show Preview';
} else {
toggleBtn.innerHTML = '<i class="mdi mdi-eye-off-outline mr-2"></i> Hide Preview';
updatePreview(); // Refresh preview when showing
}
});
// Save document
const saveButton = document.getElementById('save-document');
const titleInput = document.getElementById('doc-title');
const categorySelect = document.getElementById('doc-category');
const notification = document.getElementById('save-notification');
saveButton.addEventListener('click', function() {
console.log("Save button clicked");
const documentData = {
title: titleInput.value.trim() || 'Untitled Document',
content: editor.getValue(),
category_id: categorySelect.value,
tags: Array.from(document.querySelectorAll('.tag')).map(tag => tag.textContent.trim())
};
{% if document and document.id %}
documentData.id = {{ document.id }};
console.log("Updating existing document:", documentData.id);
{% else %}
console.log("Creating new document");
{% endif %}
console.log("Sending document data:", documentData);
fetch('/api/document', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify(documentData)
})
.then(response => {
if (!response.ok) {
throw new Error('Server responded with an error: ' + response.status);
}
return response.json();
})
.then(data => {
console.log("Document saved successfully:", data);
// Show save notification
notification.classList.remove('translate-y-16', 'opacity-0');
// Hide notification after 3 seconds
setTimeout(() => {
notification.classList.add('translate-y-16', 'opacity-0');
}, 3000);
// If this is a new document, redirect to edit page
{% if not document %}
if (data.id) {
console.log("Redirecting to edit page for new document:", data.id);
window.location.href = `/document/${data.id}/edit`;
}
{% endif %}
})
.catch(error => {
console.error('Error saving document:', error);
alert('Error saving document: ' + error.message);
});
});
// Tags functionality
const tagInput = document.getElementById('doc-tags');
const tagList = document.querySelector('.tag-list');
function addTag(tagName) {
const tag = document.createElement('span');
tag.className = 'tag px-2 py-1 bg-primary/20 text-primary rounded-full text-xs flex items-center';
tag.innerHTML = `${tagName}<span class="remove-tag ml-1 cursor-pointer">&times;</span>`;
// Add remove functionality
tag.querySelector('.remove-tag').addEventListener('click', function() {
tag.remove();
});
tagList.appendChild(tag);
tagInput.value = '';
}
tagInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const tagName = tagInput.value.trim();
if (tagName) {
// Check for duplicate
const existingTags = Array.from(document.querySelectorAll('.tag')).map(tag =>
tag.textContent.trim().toLowerCase());
if (!existingTags.includes(tagName.toLowerCase())) {
addTag(tagName);
}
}
}
});
// Setup existing tag removal
document.querySelectorAll('.remove-tag').forEach(btn => {
btn.addEventListener('click', function() {
this.parentElement.remove();
});
});
});
</script>
{% endblock %}