Chat: Add markdown preview toggle with client-side rendering and user preference
- Add renderMarkdownPreview() function to app.js matching server-side Go implementation - Support headers (#, ##, ###), bold (**text**), italic (*text*), code (`code`), code blocks (```lang), lists (* item), and @mentions - Add Edit/Preview tab UI above chat textarea with active tab highlighting - Preview updates in real-time with 300ms debounce while typing - Respect user's markdown_preview_default preference from settings (edit or preview) - Pass user preferences to chat template via ChatHandler - Add markdown tab and preview container styles matching beige/blue/pink theme - Preview shows formatted HTML identical to actual chat messages - Support dark mode with appropriate color adjustments for tabs and preview - Tabs use cyan accent for active state, orange for inactive hover User can switch between editing markdown and seeing live preview of formatted output.jocadbz
parent
e76049a353
commit
ffe9f30c0a
|
|
@ -223,12 +223,21 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
allUsernamesJSON, _ := json.Marshal(allUsernames)
|
allUsernamesJSON, _ := json.Marshal(allUsernames)
|
||||||
|
|
||||||
|
// Get user preferences for markdown preview default
|
||||||
|
prefs, err := models.GetUserPreferences(app.DB, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error fetching user preferences: %v", err)
|
||||||
|
// Create default if not found
|
||||||
|
prefs, _ = models.CreateDefaultPreferences(app.DB, userID)
|
||||||
|
}
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
PageData
|
PageData
|
||||||
Board models.Board
|
Board models.Board
|
||||||
Messages []models.ChatMessage
|
Messages []models.ChatMessage
|
||||||
AllUsernames template.JS
|
AllUsernames template.JS
|
||||||
CurrentUsername string
|
CurrentUsername string
|
||||||
|
MarkdownPreviewDefault string
|
||||||
}{
|
}{
|
||||||
PageData: PageData{
|
PageData: PageData{
|
||||||
Title: "ThreadR Chat - " + board.Name,
|
Title: "ThreadR Chat - " + board.Name,
|
||||||
|
|
@ -239,10 +248,11 @@ func ChatHandler(app *App) http.HandlerFunc {
|
||||||
StaticPath: app.Config.ThreadrDir + "/static",
|
StaticPath: app.Config.ThreadrDir + "/static",
|
||||||
CurrentURL: r.URL.Path,
|
CurrentURL: r.URL.Path,
|
||||||
},
|
},
|
||||||
Board: *board,
|
Board: *board,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
AllUsernames: template.JS(allUsernamesJSON),
|
AllUsernames: template.JS(allUsernamesJSON),
|
||||||
CurrentUsername: currentUsername,
|
CurrentUsername: currentUsername,
|
||||||
|
MarkdownPreviewDefault: prefs.MarkdownPreviewDefault,
|
||||||
}
|
}
|
||||||
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
|
if err := app.Tmpl.ExecuteTemplate(w, "chat", data); err != nil {
|
||||||
log.Printf("Error executing template in ChatHandler: %v", err)
|
log.Printf("Error executing template in ChatHandler: %v", err)
|
||||||
|
|
|
||||||
115
static/app.js
115
static/app.js
|
|
@ -371,3 +371,118 @@ function showDraftIndicator(content, timestamp, onRestore, onDiscard) {
|
||||||
|
|
||||||
return indicator;
|
return indicator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Markdown Preview Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Escape HTML to prevent XSS
|
||||||
|
function escapeHTML(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process inline markdown (bold, italic, code, mentions)
|
||||||
|
function processInlineMarkdown(line) {
|
||||||
|
// Inline code first (to avoid processing markdown inside code)
|
||||||
|
line = line.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
|
// Bold: **text** or __text__
|
||||||
|
line = line.replace(/\*\*([^\*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
line = line.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||||
|
|
||||||
|
// Italic: *text* or _text_
|
||||||
|
line = line.replace(/\*([^\*]+)\*/g, '<em>$1</em>');
|
||||||
|
line = line.replace(/_([^_]+)_/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Mentions: @username
|
||||||
|
line = line.replace(/@(\w+)/g, '<span class="chat-message-mention">@$1</span>');
|
||||||
|
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render markdown to HTML (matching Go implementation)
|
||||||
|
function renderMarkdownPreview(content) {
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Extract code blocks first and replace with placeholders
|
||||||
|
const codeBlocks = [];
|
||||||
|
let codeBlockCounter = 0;
|
||||||
|
|
||||||
|
content = content.replace(/```(\w*)\n([\s\S]*?)\n```/g, (match, lang, code) => {
|
||||||
|
const escapedCode = escapeHTML(code);
|
||||||
|
let renderedBlock;
|
||||||
|
if (lang) {
|
||||||
|
renderedBlock = `<pre><code class="language-${lang}">${escapedCode}</code></pre>`;
|
||||||
|
} else {
|
||||||
|
renderedBlock = `<pre><code>${escapedCode}</code></pre>`;
|
||||||
|
}
|
||||||
|
const placeholder = `<!--CODEBLOCK_${codeBlockCounter}-->`;
|
||||||
|
codeBlocks[codeBlockCounter] = renderedBlock;
|
||||||
|
codeBlockCounter++;
|
||||||
|
return placeholder;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process lines
|
||||||
|
const lines = content.split('\n');
|
||||||
|
let inList = false;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if (trimmedLine.startsWith('### ')) {
|
||||||
|
if (inList) { html += '</ul>\n'; inList = false; }
|
||||||
|
html += '<h3>' + processInlineMarkdown(trimmedLine.substring(4)) + '</h3>\n';
|
||||||
|
continue;
|
||||||
|
} else if (trimmedLine.startsWith('## ')) {
|
||||||
|
if (inList) { html += '</ul>\n'; inList = false; }
|
||||||
|
html += '<h2>' + processInlineMarkdown(trimmedLine.substring(3)) + '</h2>\n';
|
||||||
|
continue;
|
||||||
|
} else if (trimmedLine.startsWith('# ')) {
|
||||||
|
if (inList) { html += '</ul>\n'; inList = false; }
|
||||||
|
html += '<h1>' + processInlineMarkdown(trimmedLine.substring(2)) + '</h1>\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lists
|
||||||
|
if (trimmedLine.startsWith('* ') || trimmedLine.startsWith('- ')) {
|
||||||
|
if (!inList) {
|
||||||
|
html += '<ul>\n';
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
const listContent = trimmedLine.substring(2);
|
||||||
|
html += '<li>' + processInlineMarkdown(listContent) + '</li>\n';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close list if we're not in a list item anymore
|
||||||
|
if (inList) {
|
||||||
|
html += '</ul>\n';
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular paragraphs
|
||||||
|
if (trimmedLine !== '') {
|
||||||
|
html += '<p>' + processInlineMarkdown(trimmedLine) + '</p>\n';
|
||||||
|
} else {
|
||||||
|
html += '\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close list if still open
|
||||||
|
if (inList) {
|
||||||
|
html += '</ul>\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace code block placeholders
|
||||||
|
codeBlocks.forEach((block, index) => {
|
||||||
|
html = html.replace(`<!--CODEBLOCK_${index}-->`, block);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up excessive newlines
|
||||||
|
html = html.replace(/\n{3,}/g, '\n\n');
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,99 @@
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
.markdown-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
border-bottom: 1px solid #001858;
|
||||||
|
}
|
||||||
|
.markdown-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background-color: #f3d2c1;
|
||||||
|
color: #001858;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
cursor: pointer;
|
||||||
|
border-right: 1px solid #001858;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.markdown-tab:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
.markdown-tab:hover {
|
||||||
|
background-color: #fef6e4;
|
||||||
|
}
|
||||||
|
.markdown-tab.active {
|
||||||
|
background-color: #8bd3dd;
|
||||||
|
color: #001858;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.markdown-content-container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
.chat-input textarea,
|
||||||
|
.markdown-preview {
|
||||||
|
display: none;
|
||||||
|
resize: none;
|
||||||
|
height: 50px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.chat-input textarea.markdown-visible,
|
||||||
|
.markdown-preview.markdown-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.markdown-preview {
|
||||||
|
border: 1px solid #001858;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #fef6e4;
|
||||||
|
color: #001858;
|
||||||
|
min-height: 50px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.markdown-preview h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.markdown-preview h2 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.markdown-preview h3 {
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.markdown-preview p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.markdown-preview ul {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 2em;
|
||||||
|
}
|
||||||
|
.markdown-preview code {
|
||||||
|
background-color: #f3d2c1;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.markdown-preview pre {
|
||||||
|
background-color: #f3d2c1;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.markdown-preview pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
.post-actions {
|
.post-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5px;
|
top: 5px;
|
||||||
|
|
@ -410,6 +503,29 @@
|
||||||
border-color: #f582ae;
|
border-color: #f582ae;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.markdown-tab {
|
||||||
|
background-color: #555;
|
||||||
|
color: #fef6e4;
|
||||||
|
border-color: #fef6e4;
|
||||||
|
}
|
||||||
|
.markdown-tab:hover {
|
||||||
|
background-color: #666;
|
||||||
|
}
|
||||||
|
.markdown-tab.active {
|
||||||
|
background-color: #8bd3dd;
|
||||||
|
color: #001858;
|
||||||
|
}
|
||||||
|
.markdown-preview {
|
||||||
|
background-color: #333;
|
||||||
|
color: #fef6e4;
|
||||||
|
border-color: #fef6e4;
|
||||||
|
}
|
||||||
|
.markdown-preview code {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
.markdown-preview pre {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -466,7 +582,14 @@
|
||||||
<span id="reply-username">Replying to </span>
|
<span id="reply-username">Replying to </span>
|
||||||
<button onclick="cancelReply()">X</button>
|
<button onclick="cancelReply()">X</button>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="chat-input-text" placeholder="Type a message..."></textarea>
|
<div class="markdown-tabs">
|
||||||
|
<button class="markdown-tab active" data-tab="edit" onclick="switchMarkdownTab('edit')">Edit</button>
|
||||||
|
<button class="markdown-tab" data-tab="preview" onclick="switchMarkdownTab('preview')">Preview</button>
|
||||||
|
</div>
|
||||||
|
<div class="markdown-content-container">
|
||||||
|
<textarea id="chat-input-text" placeholder="Type a message..." class="markdown-visible"></textarea>
|
||||||
|
<div id="chat-preview" class="markdown-preview"></div>
|
||||||
|
</div>
|
||||||
<button onclick="sendMessage()">Send</button>
|
<button onclick="sendMessage()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -829,6 +952,57 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Markdown preview functionality
|
||||||
|
let currentTab = '{{.MarkdownPreviewDefault}}' || 'edit';
|
||||||
|
let previewUpdateTimeout;
|
||||||
|
|
||||||
|
function switchMarkdownTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
const textarea = document.getElementById('chat-input-text');
|
||||||
|
const preview = document.getElementById('chat-preview');
|
||||||
|
const tabs = document.querySelectorAll('.markdown-tab');
|
||||||
|
|
||||||
|
// Update tab styling
|
||||||
|
tabs.forEach(t => {
|
||||||
|
if (t.dataset.tab === tab) {
|
||||||
|
t.classList.add('active');
|
||||||
|
} else {
|
||||||
|
t.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide content
|
||||||
|
if (tab === 'edit') {
|
||||||
|
textarea.classList.add('markdown-visible');
|
||||||
|
preview.classList.remove('markdown-visible');
|
||||||
|
textarea.focus();
|
||||||
|
} else {
|
||||||
|
textarea.classList.remove('markdown-visible');
|
||||||
|
preview.classList.add('markdown-visible');
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const textarea = document.getElementById('chat-input-text');
|
||||||
|
const preview = document.getElementById('chat-preview');
|
||||||
|
const content = textarea.value;
|
||||||
|
|
||||||
|
if (content.trim() === '') {
|
||||||
|
preview.innerHTML = '<p style="opacity: 0.5; font-style: italic;">Nothing to preview. Type some markdown in the Edit tab.</p>';
|
||||||
|
} else {
|
||||||
|
preview.innerHTML = renderMarkdownPreview(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preview on input (debounced)
|
||||||
|
document.getElementById('chat-input-text').addEventListener('input', () => {
|
||||||
|
if (currentTab === 'preview') {
|
||||||
|
clearTimeout(previewUpdateTimeout);
|
||||||
|
previewUpdateTimeout = setTimeout(updatePreview, 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
window.onload = function() {
|
window.onload = function() {
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
const messagesContainer = document.getElementById('chat-messages');
|
const messagesContainer = document.getElementById('chat-messages');
|
||||||
|
|
@ -906,6 +1080,9 @@
|
||||||
originalSendMessage();
|
originalSendMessage();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize markdown preview tab based on user preference
|
||||||
|
switchMarkdownTab(currentTab);
|
||||||
|
|
||||||
// Initial check for scroll position
|
// Initial check for scroll position
|
||||||
checkScrollPosition();
|
checkScrollPosition();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue