532 lines
No EOL
20 KiB
HTML
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">×</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">×</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 %} |